Compare commits

...

206 Commits

Author SHA1 Message Date
github-actions[bot] 510e0d082e 🆙 Bump version to 4.2.44 [skip ci] 2026-06-12 13:53:22 +00:00
DuckieTM e13c7fdbb6 Merge pull request #168 from hotellidev/multicolorfurnifix
Fix multicolor furni in furni editor
2026-06-12 15:52:23 +02:00
hotellidev 2a28fbd2e5 Fix multicolor furni in furni editor 2026-06-11 04:07:22 +03:00
github-actions[bot] cd60cba355 🆙 Bump version to 4.2.43 [skip ci] 2026-06-10 13:32:38 +00:00
DuckieTM e62f461962 Merge pull request #167 from duckietm/dev
㊙️ Security updates
2026-06-10 15:31:38 +02:00
duckietm 7f8c98e4f3 ㊙️ Security updates 2026-06-10 15:31:18 +02:00
github-actions[bot] d95e09e64f 🆙 Bump version to 4.2.42 [skip ci] 2026-06-10 13:10:40 +00:00
DuckieTM ebe0690e46 Merge pull request #166 from duckietm/dev
🆙 Fix multiheight
2026-06-10 15:09:31 +02:00
duckietm 0dda0ae0f7 🆙 Fix multiheight 2026-06-10 15:09:14 +02:00
github-actions[bot] 54ab6613f1 🆙 Bump version to 4.2.41 [skip ci] 2026-06-10 12:18:32 +00:00
DuckieTM 9fda766ba5 Merge pull request #165 from duckietm/dev
🆙 Fix Group Forum buy
2026-06-10 14:17:32 +02:00
duckietm 3da9325344 🆙 Fix Group Forum buy 2026-06-10 14:17:17 +02:00
github-actions[bot] 770739c256 🆙 Bump version to 4.2.40 [skip ci] 2026-06-10 08:16:33 +00:00
DuckieTM 3ec468993a Merge pull request #164 from duckietm/dev
Dev
2026-06-10 10:15:20 +02:00
duckietm 0e0f1cbb15 🆙 Navigator Group Filter 2026-06-10 10:14:49 +02:00
DuckieTM daeda761cd Merge pull request #163 from simoleo89/feat/security-concurrency-economy-hardening
Security, concurrency & economy hardening + dependency upgrades and modernization
2026-06-10 06:43:45 +02:00
DuckieTM 0906048a3a Merge pull request #162 from RemcoEpicnabbo/main
Handle '.' in vending_ids parsing
2026-06-10 06:43:31 +02:00
simoleo89 19cde45d3e fix(marketplace): avoid inventory desync on failed offer insert
Expose whether a marketplace offer was persisted before mutating inventory state, refuse sells whose database insert failed, and synchronize the sold timestamp into the online seller's in-memory offer when present. This keeps failed or racing marketplace operations from desynchronizing credits/items.
2026-06-09 22:02:53 +02:00
simoleo89 8161e3d7e5 fix(moderation): harden ban and modtool edge cases
Use executeUpdate with generated keys for offline ban inserts, return an empty result when an offline target cannot be loaded, and make ban commands handle empty results instead of indexing blindly. Modtool chatlog requests now guard missing users instead of dereferencing null.
2026-06-09 22:02:33 +02:00
simoleo89 5c0f2d2855 fix(session): separate forced disconnects from resume parking
Add a forced dispose path for bans, RCON disconnects, logout/account endpoints, plugin-cancelled login, duplicate login replacement, and late MAC-ban enforcement. Soft channel closes still park a session for reconnect, while security-driven closes now bypass session resume. Also null-guard client/channel disposal and cover the contract with focused tests.
2026-06-09 22:02:07 +02:00
simoleo89 d984461cc0 fix(login): don't reject login when the machine fingerprint arrives after the SSO ticket
The Nitro renderer sends the UniqueID (machine fingerprint) packet right AFTER the SSOTicket, so Habbo.connect() ran before the machineId was set and returned false on the empty machineId — silently disposing the client (WS closed with Netty's default "Bye"), so login never completed and no SecureLoginOK was sent.

- Habbo.connect(): only set machineID + run the MAC-ban check when the fingerprint is already present; never reject the login solely for a missing machineId (also drop a duplicated MAC/IP-ban block).

- MachineIDEvent: enforce the MAC ban when the fingerprint arrives after login, preserving the ban check that connect() now defers.
2026-06-09 20:50:12 +02:00
simoleo89 61ea33ac28 docs(config): document new networking/threading keys from the hardening batch
Add commented examples for the config keys introduced by this PR so operators
can discover and tune them (defaults apply if unset):
- ws.ip.header.trusted (trusted reverse-proxy gate for the forwarded-IP header)
- io.packet.handler.threads (game packet-handler pool, off the Netty I/O loop)
- auth.http.pool.size (dedicated /api/auth/* worker pool)
- io.netty.allocator.pooled (opt-in pooled ByteBuf allocator)
2026-06-09 20:06:31 +02:00
simoleo89 b6ee400b83 refactor: drop Joda-Time (-> java.time) and make protocol charsets explicit
Modernization following the dependency upgrades:
- Joda-Time was used in exactly one place (ModToolSanctionInfoComposer, to
  subtract probation days from a Date). Migrated to java.time
  (Instant/ZoneId.systemDefault, calendar-accurate like the old Joda call) and
  removed the joda-time dependency entirely — confirmed gone from the shaded jar.
- Make string<->bytes conversions explicitly UTF-8 instead of relying on the
  platform default. Most importantly the wire codec (ClientMessage.readString /
  ServerMessage.appendString) — both sides now pinned to UTF-8 so international
  characters are robust regardless of -Dfile.encoding. Also RCONServerHandler,
  PluginManager and the WS origin-forbidden response.

Verified: clean compile, 15/15 tests, shaded jar.
2026-06-09 20:05:31 +02:00
simoleo89 62104596ac refactor(netty): migrate off the deprecated NioEventLoopGroup (Netty 4.2)
Netty 4.2 deprecates NioEventLoopGroup in favour of the generic
MultiThreadIoEventLoopGroup driven by an IoHandlerFactory. Server.java now
builds its boss/worker groups with MultiThreadIoEventLoopGroup(..,
NioIoHandler.newFactory()) — functionally equivalent, and the codebase now
compiles with zero deprecation warnings. Verified: compile, 15/15 tests, shaded
jar.
2026-06-09 20:05:31 +02:00
simoleo89 fad6be158a chore(deps): upgrade Netty (4.2), HikariCP (7) and JUnit (6) to latest major
Major-version upgrades of the three the previous bump deliberately held back.
Verified: clean compile, all 15 tests run green (surefire 3.5.2 drives the JUnit 6
platform fine — no extra launcher dep needed), and the shaded jar assembles.

- io.netty:netty-all            4.1.135.Final -> 4.2.15.Final
- com.zaxxer:HikariCP           6.3.3         -> 7.0.2
- org.junit.jupiter:junit-jupiter 5.14.4      -> 6.1.0

Notes:
- Stayed on Netty 4.2 (GA), not 5.0 which is still Alpha. No source changes
  needed; the channel ALLOCATOR is set explicitly so 4.2's new default adaptive
  allocator doesn't apply. NioEventLoopGroup is deprecated in 4.2 but still
  functions as before (left as-is to avoid an event-loop behavioural change).
  netty-all 4.2 pulls more transitive modules, so the fat jar grows (~20->32 MB).
- HikariCP 7.x baselines Java 17; our HikariConfig usage is unchanged.
2026-06-09 20:05:31 +02:00
simoleo89 a9f1903465 chore(deps): update dependencies to latest stable
Bumped to the latest stable within each safe major line (no source changes
needed — all APIs compatible; verified with clean compile + test + shaded jar):

- io.netty:netty-all            4.1.118 -> 4.1.135.Final
- com.google.code.gson:gson     2.11.0  -> 2.14.0
- org.mariadb.jdbc:*            3.5.1    -> 3.5.8
- com.zaxxer:HikariCP           6.2.1    -> 6.3.3
- org.apache.commons:commons-lang3 3.17.0 -> 3.20.0
- org.jsoup:jsoup               1.18.3   -> 1.22.2
- org.slf4j:slf4j-api           2.0.16   -> 2.0.18
- ch.qos.logback:logback-classic 1.5.15  -> 1.5.34
- org.fusesource.jansi:jansi    2.4.1    -> 2.4.3
- joda-time:joda-time           2.13.0   -> 2.14.2
- org.eclipse.angus:jakarta.mail 2.0.3   -> 2.0.5
- org.junit.jupiter:junit-jupiter 5.10.2 -> 5.14.4
- maven-surefire-plugin         3.2.5    -> 3.5.2

Deliberately NOT changed:
- Stayed on the netty 4.1.x line (4.2/5.0 are new majors with API changes),
  slf4j 2.0.x (logback 1.5.x requires it), junit 5.x (6.x needs a new baseline),
  and HikariCP 6.x (kept the current major for DB-pool stability; 7.x available).
- trove4j 3.0.3, commons-math3 3.6.1, jbcrypt 0.4 — already at their final
  (unmaintained) releases.
- compiler source/target=19 / release=21 — intentional per project convention.
2026-06-09 20:05:31 +02:00
simoleo89 af82352f24 feat: configurable pool sizes (#2) + pool-safe buffers and opt-in pooled allocator (#5)
#2 — tunable thread pools (sensible defaults kept):
- io.packet.handler.threads overrides the packet-handler EventExecutorGroup size
  (default max(16, 2x cores)).
- auth.http.pool.size overrides the auth HTTP pool max threads (default 16).

#5 — Netty buffer pooling:
- Make the crypto handlers pool-safe: GameByteEncryption/GameByteDecryption no
  longer call ByteBuf.array() on a readBytes-derived buffer (whose arrayOffset is
  non-zero under a pooled allocator, which would have read/encrypted the wrong
  region). They now copy the readable region into a plain byte[] (offset-safe)
  and wrap the result — also drops one intermediate buffer allocation. This is
  correct for the current unpooled allocator too. (ServerMessage uses its own
  Unpooled buffer, and ClientMessage reads via buffer methods, so both are
  already offset-safe.)
- Add a shared channel allocator selected by io.netty.allocator.pooled
  (default false = unpooled-heap, unchanged). Set true for a pooled HEAP
  allocator (preferDirect=false, so array-backed paths keep working) to cut
  per-packet alloc/GC churn. Opt-in until validated under load with the Netty
  leak detector, since unreleased pooled buffers accumulate rather than being
  GC-reclaimed.

New optional config keys (insert into emulator_settings to set/silence the
"key not found" notice): io.packet.handler.threads, auth.http.pool.size,
io.netty.allocator.pooled.
2026-06-09 20:05:30 +02:00
simoleo89 dcc23ba744 feat: housekeeping audit log + shared Gson instances
Security:
- HousekeepingAuditLog: append-only audit trail of privileged actions. There was
  no record of which operator granted ranks/currency to whom. SetUserRank,
  GiveCredits and GiveCurrency now log operator id+name, action, target, detail
  and IP. Writes are async; the housekeeping_log table is created on first use
  (CREATE TABLE IF NOT EXISTS) so no manual migration is needed.

Speed (minor):
- RCONServerHandler / PluginManager: reuse a shared Gson instead of allocating a
  new parser per request/plugin-config load (Gson is thread-safe). The wired
  Gson builders were already cached singletons.
2026-06-09 20:05:30 +02:00
simoleo89 f7556138aa feat: LIKE-wildcard escaping (security) + recycle/craft reward rollback (stability)
Security / speed:
- New util SqlLikeEscaper: escapes %, _ and \ in user search input. Applied to
  the user-facing LIKE searches (messenger user search, marketplace search,
  furni-editor search, housekeeping room search, guild member search) so a query
  like "%" can no longer match everything or trigger a needless full scan, and
  usernames containing "_" are matched literally.

Stability (item-loss fixes):
- RecycleEvent: compute the recycler reward BEFORE consuming the 8 inputs. The
  inputs were deleted from the DB first, so a null reward (misconfig) destroyed
  them permanently with nothing back. Now the inputs are only removed once the
  reward is confirmed.
- CraftingCraftItemEvent: restore the pulled ingredients to the inventory if the
  recipe can't be completed (not enough ingredients mid-pull, or reward creation
  returns null) — previously they silently vanished from the inventory.
2026-06-09 20:05:30 +02:00
simoleo89 a0910d822c fix: deep-analysis pass — self-review regressions + pre-existing logic bugs
Regressions found by an adversarial review of this branch's own diff:
- RoomCycleManager: stop holding the currentBots/currentPets monitor across the
  whole bot/pet tick — snapshot under the lock then cycle off-lock. The previous
  fix blocked place/pickup and room dispose for the full tick and inverted lock
  order vs roomUnitLock->currentBots (latent deadlock for any future cycle code
  touching roomUnitLock).
- HabboInfo: complete the currencyLock invariant — getCurrencies() now returns a
  snapshot under the lock (UserInfoCommand iterated the live Trove map off-lock,
  the exact rehash corruption the lock guards); canBuy() uses the lock-guarded
  getCredits()/getCurrencyAmount(); run() reads credits under the lock for save.
- RoomSpecialTypes: synchronize the by-id pet getters (getNest/getPetDrink/
  getPetFood/getPetToy/getPetTree) to match their now-synchronized mutators.
- AuthHttpUtil.isTrustedProxy: exact-match trusted IPs; only treat an entry as a
  range when it ends with "."/":" so "10.0.0.1" can't also trust "10.0.0.12".

Pre-existing logic bugs found by the deep subsystem analysis:
- RoomUsersComposer: the bulk (room-entry) branch wrote the guild id twice;
  the second field must be the 1/-1 group-membership flag (matches the single
  branch) — every user showed a wrong group indicator on room entry.
- BotManager.pickUpBot: room owners (and ACC_PLACEFURNI) couldn't pick up bots
  placed in their own room — added the room-owner clause that placeBot has.
- PetPickupEvent: compared user id to pet.getId() instead of pet.getUserId(), so
  a pet owner who isn't the room owner couldn't pick up their own pet.
- RoomRightsManager.refreshRightsForHabbo: in guild rooms, explicit room_rights
  were stripped (overwritten by guild level NONE); now takes the stronger of
  explicit rights and guild level.
- RoomRequestBannedUsersEvent: `!hasRights || !ACC_ANYROOMOWNER` required BOTH,
  denying legitimate owners the banned-users list — corrected to `&&`.
- InteractionPetBreedingNest.breed: a crafted packet on a not-full nest deleted
  the nest furni then NPE'd (furni loss); guard petOne/petTwo/room before the
  destructive delete; ConfirmPetBreedingEvent null-checks the room.
- WiredEffectTeleport/UserFurniBase: appended item id instead of sprite id in the
  incompatible-triggers list (cosmetic wired-dialog mismatch) — matched the ~10
  other effects' getBaseItem().getSpriteId() convention.
2026-06-09 20:05:30 +02:00
simoleo89 4eb1484daf perf: run game packet handlers off the Netty I/O loop + bound A* pathfinding (P2)
Root cause from the CPU audit: every incoming packet handler ran on the Netty
I/O event loop (MULTI_THREADED_PACKET_HANDLING is false by default), so any
blocking handler — login DB + loadHabbo, friends/polls/catalog/guild-forum
JDBC (~48 handlers), synchronous A* per walk — stalled socket I/O for every
other client sharing that I/O thread.

- WebSocketChannelInitializer: register GameMessageHandler on a dedicated
  DefaultEventExecutorGroup (max(16, 2x cores), daemon). Netty pins each channel
  to one executor in the group, so a client's packets stay strictly ordered (no
  new intra-client races) while blocking work moves off the I/O loop. The
  cross-client concurrency degree matches the already-multi-threaded I/O group,
  and this is strictly safer than the existing (order-losing) shared-pool
  MULTI_THREADED_PACKET_HANDLING mode the codebase already supported.
- GameMessageHandler: always run the handler inline (now on the group thread);
  drop the shared-pool branch (which would break per-channel ordering and also
  removes the rejectable-pool ByteBuf-drop path).
- PathfinderImpl: default the A* execution-time guard ON (25ms) so a pathological
  search returns an empty path instead of running unbounded on its thread.

Note: this changes the server's packet-threading model — verified to compile,
unit-test, and assemble the shaded jar, but should be load-tested before prod.
Group size is currently derived from CPU count; can be made a config key if
tuning is needed.
2026-06-09 20:05:30 +02:00
simoleo89 45d01876c1 fix: bound the move-blocking Future.get in RoomUserWalkEvent
roomUnit.getMoveBlockingTask().get() blocked the Netty event loop with no
timeout; a stuck/delayed move-blocking task would park the worker thread (and
every client on it) indefinitely. Wait at most 2s, then proceed with the walk.
2026-06-09 20:05:30 +02:00
simoleo89 1c4449fb88 perf: run auth HTTP endpoints off the Netty event loop (P1)
The /api/auth/* handlers ran inline on the Netty worker event loop, so their
blocking work — BCrypt (cost 12 ~tens of ms), JDBC, the Turnstile HTTPS
round-trip and SMTP — stalled every other client multiplexed on that thread; a
burst of logins/registers could freeze game traffic.

Dispatch each auth request to a dedicated bounded pool (4..16 daemon threads,
bounded queue, 503 on saturation) instead. It is deliberately SEPARATE from the
shared game ThreadPooling so auth load can't starve room cycles either. Netty
writes are thread-safe, so the endpoints' sendJson calls work unchanged from the
worker; the FullHttpRequest is released when the task finishes.

Caveat: this allows concurrent handling of pipelined requests on a single
keep-alive connection (out-of-order responses) — not a concern for the Nitro
client which is strictly request/response, but worth load-testing before prod.
2026-06-09 20:05:29 +02:00
simoleo89 373d0399c1 fix: trusted-proxy gate for forwarded IP, wired-var cache + ghost-session cleanup
Security (S3):
- AuthHttpUtil/WebSocketHttpHandler: only honour the configured ws.ip.header
  forwarded-IP header when the DIRECT peer is a trusted reverse proxy, instead
  of trusting it unconditionally. Loopback is always trusted; extra proxies can
  be allow-listed (exact IP or string prefix, comma-separated) via the new
  `ws.ip.header.trusted` config key — default-deny so the header can't be
  spoofed from the open internet to evade per-IP rate limiting and IP bans.
  Also take only the first comma token when setting the game-session WS_IP.

Leak cleanup (C4):
- WiredVariableReferenceSupport.invalidateRoom(): drop a room's shared
  wired-variable assignment caches; called from Room.dispose so the static
  USER/ROOM_ASSIGNMENT_CACHE maps don't retain entries for the JVM lifetime.
- SessionResumeManager.parkHabbo: if the scheduler refuses the grace-expiry
  task (future == null), disconnect immediately instead of parking an
  un-reapable GhostSession that would pin the Habbo + room refs forever.

Note: ws.ip.header.trusted defaults to loopback-only; deployments whose proxy
is on another host must add its IP/prefix to that key or client IPs will
collapse to the proxy address.
2026-06-09 20:05:29 +02:00
simoleo89 01c17c0511 fix: wired double-fire guard, RoomUnit path race, roomItems iteration, Netty CVE
Continuation of the concurrency hardening from the audit:
- InteractionWired/WiredHandler (E4): add an atomic per-box processing guard so
  one trigger box is handled by a single thread at a time, making the cooldown
  check-and-set effectively atomic; mark `cooldown` volatile. Prevents a packet
  thread and the room cycle thread from double-firing the same wired stack
  (double teleport/reward).
- RoomUnit (C1): the walk path is now a volatile ConcurrentLinkedDeque instead of
  a plain LinkedList, so the room cycle popping steps can't corrupt it while a
  walk packet rebuilds it via findPath/setPath.
- RoomItemManager (C2): iterate roomItems under its own monitor in getFloorItems/
  getWallItems/getPostItNotes/getUserUniqueFurniCount/getItemsAt, matching the
  existing put/remove sync sites — stops place/pickup from corrupting the
  traversal into a silently-incomplete item set.
- pom.xml (S4): bump netty-all 4.1.115 -> 4.1.118.Final (CVE-2025-24970 SslHandler
  pre-auth DoS, CVE-2025-25193).
2026-06-09 20:05:29 +02:00
simoleo89 d1570d3574 fix: economy-integrity, currency thread-safety, and resource-leak hardening
From the full-codebase audit. Economy/security (Batch A):
- CatalogBuyItemEvent: clamp client `count` to 1..100 — the club-offer branch
  accumulated cost in plain ints, so a huge count overflowed to a negative
  total, bypassed the affordability checks and CREDITED the buyer (free
  currency/subscription exploit).
- HousekeepingGiveCredits/GiveCurrency: bound `amount` to +/-1e9 to stop
  overflow/negative-balance grants via the privileged path.
- RoomTrade: synchronize accept/confirm/offer/remove and add a `completed`
  re-entry guard so two simultaneous confirms can't run tradeItems() twice
  (item/credit duplication).
- HabboInfo: serialize credits + currencies read-modify-write and the
  saveCurrencies snapshot on a dedicated lock (never held across DB I/O) —
  fixes lost updates and Trove rehash-during-iteration corruption between the
  credit-roller thread and purchase/trade handlers.
- AchievementManager/HabboStats: atomic incrementProgress() so concurrent
  progress sources don't lose updates.

Resource/stability (Batch B):
- GameMessageRateLimit: release the wrapped ByteBuf on every drop path
  (ClientMessage isn't ReferenceCounted, so the decoder's auto-release is a
  no-op) — fixes a refcount leak on pre-auth/rate-limited packets.
- AuthRateLimiter: opportunistically evict window-expired STATE/PROBE_STATE
  entries — previously grew unbounded, one entry per unique client IP.
- ForumThread/ForumThreadComment: close getGeneratedKeys() ResultSets via
  try-with-resources, and create the first comment after the thread's
  connection is released (was holding two pooled connections at once).
- DatabasePool: add socketTimeout/connectTimeout/tcpKeepAlive so a stalled
  MariaDB can't pin a pooled connection (and its thread) indefinitely.

Concurrency visibility (Batch C, partial):
- Room: mark allowBotsWalk/allowPets/allowPetsEat volatile (read every cycle,
  written from settings handlers on another thread).
2026-06-09 20:05:29 +02:00
simoleo89 c98d3a3205 fix: guard double gift-open and harden client string reads
- InteractionGift/OpenRecycleBoxEvent: add an atomic open-once guard so two
  near-simultaneous OpenRecycleBox packets can't both schedule the async,
  delayed OpenGift before the wrapper is removed (redundant double-process).
- ClientMessage.readString: treat the length prefix as unsigned (mask 0xFFFF)
  and clamp to the buffered bytes, so a bogus/oversized length no longer
  throws mid-read and desyncs the remaining fields of the packet.
2026-06-09 20:05:29 +02:00
simoleo89 da1fd01074 fix: address bug-hunt findings across security, concurrency, trade & wired
Security
- HousekeepingSetUserRankEvent: add rank-ceiling guard (reject granting a
  rank above the operator's own and modifying a higher-ranked target),
  mirroring GiveRankCommand — closes a privilege-escalation path.

Trade integrity
- RoomTrade.clearAccepted now also resets confirmed; a stale confirmed=true
  let a user strip their side and still complete once the partner re-confirms.

Concurrency
- RoomCycleManager: iterate the synchronized bot/pet maps under their own
  monitor (lock order stays one-directional vs addBot/addPet — no deadlock).
- RoomSpecialTypes: synchronize nest/petDrink/petFood/petToy/petTree writers
  on the same monitor their getters already use.
- HabboStats: synchronize achievement-progress map accessors.
- RebugKickBallAction: drop redundant direct mutation of the shared tile-cache
  sets (updateTile invalidates them right after) — removes a data race.

Robustness
- Wired legacy parsers (HabboCount, NotHabboCount, MatchStatePosition,
  MoveRotateFurni): guard length/format so one malformed row no longer aborts
  the whole room's wired load.
- RoomLayout: fill malformed/short heightmap rows with INVALID tiles instead
  of leaving nulls, and bounds-check door coordinates.
- FurnidataWatcher: defer (instead of drop) a throttled delta so furni-name
  changes are never lost between broadcasts.
- GuildManager.getGuildMembers: fix LIMIT row-count (page size 14, not
  offset+14) so member pages no longer overlap from page 1 on.
2026-06-09 20:05:29 +02:00
Remco Epicnabbo f7bd452cb0 Handle '.' in vending_ids parsing
Normalize vending_ids by replacing semicolons and dots with commas before splitting. This ensures values separated by '.' are treated like other delimiters and parsed correctly as integers, avoiding parsing errors from unexpected separators.
2026-06-09 17:20:02 +02:00
duckietm 48fcd3f78b 🆙 Update SQL 2026-06-08 11:26:03 +02:00
DuckieTM 1275254fa0 Merge pull request #161 from duckietm/main
Sync Main to DEV
2026-06-08 07:31:07 +02:00
duckietm 7ed7a1ec5a 🆙 update the SQL 2026-06-08 07:18:37 +02:00
github-actions[bot] d383c43bbf 🆙 Bump version to 4.2.39 [skip ci] 2026-06-07 21:19:40 +00:00
DuckieTM 44bfcc49b4 Merge pull request #160 from simoleo89/feat/furnidata-source-diagnostics
Resolve furnidata from configured source
2026-06-07 23:18:42 +02:00
DuckieTM b0ffb64cb2 Merge pull request #159 from hotellidev/nulljsonfix
Safely handle JsonNull types
2026-06-07 23:18:20 +02:00
DuckieTM 1f4eef8e2e 🆙 Added null check to wall /floor and background 2026-06-07 23:14:25 +02:00
simoleo89 bfc6ff21a5 feat: resolve furnidata by configured source 2026-06-07 22:00:20 +02:00
John Doe ea88934e9e Safely handle JsonNull types 2026-06-07 21:45:15 +03:00
github-actions[bot] bb4b9fb7f4 🆙 Bump version to 4.2.38 [skip ci] 2026-06-07 06:56:00 +00:00
DuckieTM 84d7968b76 Merge pull request #158 from duckietm/dev
Dev
2026-06-07 08:55:03 +02:00
DuckieTM f5bf4baa79 🆙 move SQL 2026-06-07 08:54:43 +02:00
DuckieTM 4a02d22061 Merge pull request #157 from simoleo89/fix/messenger-offline-friend-look
fix(messenger): send friend look for offline friends in friend list
2026-06-07 08:23:17 +02:00
DuckieTM 14854efaeb Merge pull request #156 from simoleo89/feat/furni-editor
feat(furni): server-authoritative furni names + in-client Furni Editor (edit/search/sort/import)
2026-06-07 08:23:00 +02:00
simoleo89 564c8d647e fix(messenger): send friend look for offline friends in friend list
FriendsComposer only serialized a buddy's look when online, sending an
empty string for offline friends. The look is already loaded from the DB
for every friend in Messenger.loadFriends (SELECT users.look), so the
gate just discarded valid data: offline friends rendered with the
anonymous/standard avatar in the friend list and messenger, while their
profile (fetched separately) showed the real figure.

Always serialize row.getLook(). StaffChatBuddy keeps a non-null look
("ADM") so there is no NPE risk, and UpdateFriendComposer already sent
the look unconditionally, so this only aligns the initial friend list.
2026-06-07 00:34:50 +02:00
simoleo89 0e7138a721 feat(furnidata): seed furnidata feature config keys (021 migration)
The names-server + watch + import config keys read by FurnitureTextProvider /
FurnidataWatcher / FurniEditorImportTextEvent were never seeded — a fresh
install logged 'Config key not found' for each and they were not DB-editable.
Seed portable defaults (items.furnidata.path empty → derives from
furni.editor.asset.base.path; booleans true/false; import URL = habbo.it).
2026-06-06 18:27:12 +02:00
simoleo89 76eb1ecd05 fix(furnidata): furnidata_edit_log charset utf8mb3 -> utf8mb4
utf8mb3 is deprecated (removed in MySQL 9.0) and can lose data on
emoji / 4-byte characters in audited furni names/descriptions. Use
utf8mb4/utf8mb4_unicode_ci (live table converted via ALTER).
2026-06-06 17:31:13 +02:00
simoleo89 4621ed62b7 feat(furni-editor): server-side Habbo furnidata import (packet 10049)
FurniEditorImportTextEvent (incoming 10049, ACC_CATALOGFURNI): resolves
the classname, fetches the admin-configured furnidata URL via HttpClient
with a TTL cache (furni.editor.import.url / .cache.ms, default habbo.it),
finds name/description by classname and returns them via
FurniEditorImportTextResultComposer (outgoing 10049). URL is DB-configured
only (no client-supplied URL -> no SSRF); serves stale cache on failure.
2026-06-06 17:31:13 +02:00
simoleo89 2b8ce3cd91 feat(furni-editor): server-side sort for the editor search
Read sortField/sortDir from the search packet and ORDER BY a whitelisted
items_base column (id/sprite_id/item_name/public_name/type/interaction_type)
with a stable id tie-break, so sorting orders the whole result set instead
of just the page the client received. Column names come from a fixed
whitelist (never raw input) so the dynamic ORDER BY stays injection-safe.
2026-06-06 17:31:12 +02:00
simoleo89 57c36da795 feat(furni-editor): mirror furnidata display name into items_base.public_name
On a successful furnidata name update (10046), after the JSON write +
10047 broadcast, also UPDATE items_base.public_name to the new
(sanitized) name and refresh the in-memory Item cache via loadItems()
so Item.getFullName() stays consistent without a restart. Guarded by
name != null (description-only edits never blank the column), runs only
on the success path, outside FurnidataLock, with a parameterized
statement.
2026-06-06 17:31:12 +02:00
simoleo89 17629c210c feat(furnieditor): search also matches furnidata display names 2026-06-06 17:31:12 +02:00
simoleo89 50444003bb fix(furnidata): correct revert audit enum, sanitize audit values, config-driven maxBytes 2026-06-06 17:31:12 +02:00
simoleo89 f55b182d8e feat(furnieditor): make item_name immutable (remove from DB update whitelist) 2026-06-06 17:31:12 +02:00
simoleo89 1416cd7464 feat(furnieditor): FurniEditorRevertFurnidataEvent — restore last furnidata backup + rebroadcast 2026-06-06 17:31:12 +02:00
simoleo89 392d24b9c5 feat(furnieditor): FurniEditorUpdateFurnidataEvent — write furnidata + reindex + broadcast 10047 2026-06-06 17:31:12 +02:00
simoleo89 9dcd58d027 feat(furnidata): audit-log writer for editor furnidata edits 2026-06-06 17:31:12 +02:00
simoleo89 3b85d5fa34 feat(furnidata): expose source kind, maxBytes, reindexFromSource on the provider 2026-06-06 17:31:12 +02:00
simoleo89 43c2c2b0f1 feat(furnidata): split-tier write to winning tier with path-traversal guard 2026-06-06 17:31:12 +02:00
simoleo89 a815c1b99d feat(furnidata): FurnidataWriter single-file comment-preserving atomic write + backup 2026-06-06 17:31:12 +02:00
simoleo89 caf6ad35fa feat(furnidata): shared lock serializing watcher reindex and editor writes 2026-06-06 17:31:11 +02:00
simoleo89 258a95a269 feat(furnidata): add furnidata_edit_log audit table + editor write config keys 2026-06-06 17:31:11 +02:00
simoleo89 4944d41410 fix(items): watcher registers split-tier subdirs, real stop()/close, key.reset guard 2026-06-06 17:31:11 +02:00
simoleo89 8fb117ae73 feat(items): furnidata file watcher — debounce, throttle, delta cap to reload-hint, broadcast 2026-06-06 17:31:11 +02:00
simoleo89 7f4f7d6da9 feat(items): reindex returns sanitized furnidata delta 2026-06-06 17:31:11 +02:00
simoleo89 0cf46471f2 feat(items): FurnitureDataReloadComposer (header 10047, delta + reload-hint) 2026-06-06 17:31:11 +02:00
simoleo89 3a505cd559 fix(items): null-safe getDisplayName + log missing items.furnidata.path 2026-06-06 17:31:11 +02:00
simoleo89 f2e0f6e2d5 feat(items): source server-pronounced furni names from furnidata (6 sites) 2026-06-06 17:31:11 +02:00
simoleo89 d73573e7c5 feat(items): Item.getDisplayName() — furnidata name with public_name fallback 2026-06-06 17:31:11 +02:00
simoleo89 efb88e5957 feat(items): construct FurnitureTextProvider after ItemManager load 2026-06-06 17:31:11 +02:00
simoleo89 e7e75a285b feat(items): config-driven furnidata source resolution + init 2026-06-06 17:31:11 +02:00
simoleo89 28c3e93945 fix(items): Locale.ROOT case-folding + document sanitize cap unit + tighten cap test 2026-06-06 17:31:11 +02:00
simoleo89 5bf1d42cfb feat(items): FurnitureTextProvider — volatile index, sanitize, toggle 2026-06-06 17:31:10 +02:00
simoleo89 b162b3f4d8 fix(items): guard oversized manifest NPE in FurnidataReader + document JSON5 trailing-comma limit 2026-06-06 17:31:10 +02:00
simoleo89 86498b6b4c feat(items): FurnidataReader (single + split JSON5, path-guard, size-cap, fail-safe) 2026-06-06 17:31:10 +02:00
simoleo89 964f388594 feat(items): FurnidataEntry record 2026-06-06 17:31:10 +02:00
simoleo89 f9644d83b7 test: add JUnit 5 + surefire harness 2026-06-06 17:31:10 +02:00
github-actions[bot] 0b142d184c 🆙 Bump version to 4.2.37 [skip ci] 2026-06-05 19:21:31 +00:00
DuckieTM 867c8ff857 Merge pull request #155 from duckietm/dev
🆙 Fix the Admin Catalogue stuff
2026-06-05 21:20:31 +02:00
duckietm 5094d6ce4f 🆙 Fix the Admin Catalogue stuff 2026-06-05 14:23:05 +02:00
github-actions[bot] 2c0ef9873c 🆙 Bump version to 4.2.36 [skip ci] 2026-06-04 08:44:19 +00:00
DuckieTM dadc1b8aaf Merge pull request #153 from duckietm/dev
Dev
2026-06-04 10:43:21 +02:00
duckietm 85758b53fa 🆙 Updates Mention 2026-06-04 10:43:05 +02:00
DuckieTM 2171b5f2ec Merge pull request #152 from medievalshell/feat/mentions-hotelwide-figure
feat(mentions): hotel-wide @nick delivery + sender figure + disable-m…
2026-06-04 08:50:49 +02:00
medievalshell 46306c8205 feat(mentions): hotel-wide @nick delivery + sender figure + disable-mention persistence
- resolveHabbo() falls back to a hotel-wide online lookup so a direct @nick
  mention reaches the target even when they are in a different room (was
  resolved only within the sender's room).
- HabboMention now carries the sender figure (live from the sender Habbo,
  history from a users.look JOIN); MentionReceived/MentionsList composers
  append it so the client can render the sender avatar in the notification.
- 009: add users_settings.mentions_enabled / mass_mentions_enabled columns
  so :disablementions / :disablemassmentions actually persist.
2026-06-04 01:27:45 +02:00
github-actions[bot] fadec887cd 🆙 Bump version to 4.2.35 [skip ci] 2026-06-03 14:45:16 +00:00
DuckieTM e614c1d64f Merge pull request #150 from duckietm/dev
Merge pull request #149 from duckietm/main
2026-06-03 16:44:04 +02:00
DuckieTM e7deea7d9d Merge pull request #149 from duckietm/main
sync to dev
2026-06-03 16:39:01 +02:00
github-actions[bot] 44ea3abd4e 🆙 Bump version to 4.2.34 [skip ci] 2026-06-03 14:37:38 +00:00
DuckieTM 609cd20ab2 Merge pull request #143 from simoleo89/feat/command-autocomplete-refactor
Structure commands alert output
2026-06-03 16:36:33 +02:00
github-actions[bot] 717a7f184f 🆙 Bump version to 4.2.33 [skip ci] 2026-06-03 14:23:40 +00:00
DuckieTM 2862446686 Merge pull request #148 from duckietm/dev
🆙 More updates mentions
2026-06-03 16:22:39 +02:00
duckietm e97e680006 🆙 More updates mentions 2026-06-03 16:20:02 +02:00
github-actions[bot] 7e59dca192 🆙 Bump version to 4.2.32 [skip ci] 2026-06-03 12:20:44 +00:00
DuckieTM 1109d53720 Merge pull request #147 from duckietm/dev
Dev
2026-06-03 14:19:42 +02:00
duckietm f12363a5b7 Merge branch 'dev' of https://github.com/duckietm/Arcturus-Morningstar-Extended into dev 2026-06-03 14:17:28 +02:00
duckietm 7d4ffec74e 🆙 Small Fixes mention 2026-06-03 14:17:25 +02:00
github-actions[bot] 281fede58c 🆙 Bump version to 4.2.31 [skip ci] 2026-06-03 08:56:45 +00:00
DuckieTM edf152485b Merge pull request #145 from duckietm/dev
Dev
2026-06-03 10:55:46 +02:00
DuckieTM 18a1bfbe90 Merge branch 'main' into dev 2026-06-03 10:55:37 +02:00
duckietm 7c32bbfd2d 🆙 wordfilter to set specific settings to prefix 2026-06-03 10:39:44 +02:00
DuckieTM 4eae206b64 Merge pull request #140 from simoleo89/feat/mentions-system
feat(mentions): server-side mention detection, persistence & packets
2026-06-03 09:49:45 +02:00
github-actions[bot] 155b2202c7 🆙 Bump version to 4.2.30 [skip ci] 2026-06-03 07:48:08 +00:00
DuckieTM 10c291eb9f Merge pull request #144 from duckietm/dev
Dev
2026-06-03 09:47:03 +02:00
duckietm 349a8c727e 🆙 Update SQL 2026-06-03 09:46:43 +02:00
duckietm 68f2b71d14 🆙 Updated Prefixes : Now use wordfilter / table custom_prefix_blacklist can be droped 2026-06-03 09:42:43 +02:00
duckietm 69a6c0d060 🆙 Make group forums private, so only memeber can view it 2026-06-03 07:46:59 +02:00
simoleo89 9f36d95dbc fix(commands): structure commands alert output 2026-06-02 18:34:50 +02:00
github-actions[bot] 885bdca0c4 🆙 Bump version to 4.2.29 [skip ci] 2026-06-02 16:03:45 +00:00
DuckieTM db035294a7 Merge pull request #142 from duckietm/dev
🆙 Updated Group buy
2026-06-02 18:02:42 +02:00
duckietm 3216ba1df6 🆙 Updated Group buy 2026-06-02 18:02:25 +02:00
Life c9a47b1fac Merge branch 'duckietm:main' into feat/mentions-system 2026-06-02 17:38:25 +02:00
github-actions[bot] 8d6b969d75 🆙 Bump version to 4.2.28 [skip ci] 2026-06-02 14:06:26 +00:00
DuckieTM b9723e0298 Merge pull request #141 from duckietm/dev
🆙 Security Fix
2026-06-02 16:05:11 +02:00
duckietm c4aae676b2 🆙 Security Fix
Thanks to @Bop:

There's a group bug where you can accept anyone into a group within MS. There's no packet validation for accepting members if the group is invite only.
This is crucial because if you allow users to have rights who are group members, your rooms can be trashed. AKA YOUR EVENT ROOMS
2026-06-02 16:04:47 +02:00
simoleo89 7624d3fbc3 feat(mentions): server-side delete packet + robust direct-nick resolution 2026-06-02 14:44:08 +02:00
github-actions[bot] 585f4dd3aa 🆙 Bump version to 4.2.27 [skip ci] 2026-06-01 06:28:06 +00:00
DuckieTM afa114d511 Merge pull request #139 from duckietm/dev
Dev
2026-06-01 08:27:01 +02:00
simoleo89 e9129576a9 feat(mentions): server-side detection, persistence and packets 2026-05-31 21:47:56 +02:00
DuckieTM 0aadd01493 Merge pull request #138 from simoleo89/feat/wheel-admin-add-remove
feat(wheel): add & remove fortune-wheel prizes from the editor
2026-05-31 15:45:10 +02:00
simoleo89 9d98fbf9ee feat(wheel): support adding & removing fortune-wheel prizes from the editor
The prize editor could only update existing rows; savePrize was UPDATE-only,
so the admin panel had no way to add a new slice or remove an old one.

- WheelManager.savePrize now takes a sortOrder and inserts when id <= 0
  (returning the generated id) or updates + re-enables when id > 0, so a
  previously removed prize can be brought back. sort_order is persisted to
  match the editor's display order.
- New WheelManager.disablePrizesNotIn(keptIds) soft-deletes (enabled = 0)
  any prize absent from the saved authoritative list. Non-destructive: rows
  stay in the table and loadPrizes already filters enabled = 1.
- WheelAdminSavePrizesEvent collects the saved ids and disables the rest
  before reloading.

No schema change (wheel_prizes already has enabled + sort_order) and no
packet change (id = 0 / omission express insert / delete on the existing
wire). Pairs with the Nitro-V3 client editor add/remove buttons.
2026-05-31 10:49:10 +02:00
DuckieTM b38274e134 Merge pull request #137 from medievalshell/Dev
fix(bans): persist client machine fingerprint so machine/super bans work
2026-05-31 10:37:54 +02:00
medievalshell 02ab30180c fix(chat): relay unknown chat bubble ids instead of resetting to default
getBubble() fell back to NORMAL (bubble 0) for any id not in the registered
BUBBLES map, so custom client-side chat bubbles (e.g. ids 253+) rendered as
the default bubble for everyone. Now unknown positive ids (<=1000) pass
through as a transient bubble carrying that id, so the server relays it and
clients render their own .bubble-<id> style. No need to enumerate each one.
2026-05-31 03:39:23 +02:00
medievalshell da63439d53 fix(bans): persist client machine fingerprint so machine/super bans work
The Nitro client already sends a strong machine fingerprint (Thumbmark,
"IID-<hash>") via the UniqueID packet (header 2490 -> MachineIDEvent), but
the emulator only stored it on the GameClient and never copied it onto the
Habbo's HabboInfo, so it was never written to users.machine_id. As a result
machine/super bans (which read users.machine_id) matched nobody.

- MachineIDEvent: when the fingerprint arrives and the Habbo is already
  loaded, copy it onto HabboInfo and persist (run the Habbo save).
- SecureLoginEvent: if the fingerprint arrived before login, copy it onto
  HabboInfo right before the login save.

This makes machine/super bans effective without changing the client.
2026-05-31 00:04:00 +02:00
github-actions[bot] bf1a29a6e8 🆙 Bump version to 4.2.26 [skip ci] 2026-05-30 05:53:48 +00:00
DuckieTM 6391d721ff Merge pull request #136 from duckietm/dev
Dev
2026-05-30 07:52:43 +02:00
DuckieTM dfea6bcf83 🆙 Updated SQL 2026-05-30 07:52:02 +02:00
DuckieTM a7f207bb76 Merge pull request #134 from medievalshell/Dev
feat: persist `scale` for room ads / branding furni
2026-05-30 07:13:59 +02:00
duckietm b7915884b6 🆙 Update Rare-Value page 2026-05-29 08:28:01 +02:00
medievalshell 478f7bdba0 feat/fix: RCON wheel+soundboard reload, robust SSO reconnect behind Cloudflare
- RCON: add updatewheel/updatesoundboard (reload WheelManager/SoundboardManager live) so the CMS admin pages apply changes without an emulator restart.

- SSO ticket is no longer single-use: loadHabbo, session-resume and performFullDisconnect no longer clear auth_ticket. Behind Cloudflare the WS is dropped and the client retries with the same ticket; clearing it caused 'non-existing SSO token' and the 'refresh twice' / kicked-on-reconnect symptoms. The ticket now lives until its TTL (auth_ticket_expires_at), is overwritten by the CMS on the next /client load, or cleared on logout.

- SessionResume: restoreSsoTicket only restores when auth_ticket is empty (don't clobber a fresh CMS ticket); GameClient.dispose only parks/disconnects when the habbo is still attached to this client (a fast reconnect may have re-attached it to the new connection).
2026-05-29 04:45:34 +02:00
medievalshell c255f1e1b4 fix: guard RoomBundleLayout against null RoomManager during catalog init
CatalogManager.loadFurnitureValues() (rare-values feature) iterates every catalog page during GameEnvironment.load(); for a RoomBundleLayout this calls getRoomManager().loadRoom(), but RoomManager is constructed after CatalogManager so getRoomManager() returns null -> NullPointerException -> boot aborts. Null-guard the room load so the bundle resolves lazily at runtime instead.
2026-05-29 00:45:02 +02:00
medievalshell 9c831a9da4 feat: grant acc_wheeladmin to staff ranks for the wheel prize editor
The wheel prize editor is gated on acc_wheeladmin (client Settings button +
server WheelAdmin{Get,Save}PrizesEvent). Upstream 008_soundboard_fortune_wheel
registers the key but only grants rank_7 (its 7-rank hotel). This portable,
idempotent migration grants it to the same ranks as acc_ads_background via
dynamic SQL over the per-rank columns — no hardcoded rank ids. Apply then
:update_permissions or restart.
2026-05-28 22:47:15 +02:00
Medievalshell 08d1ae97a7 Merge branch 'duckietm:main' into Dev 2026-05-28 22:16:17 +02:00
github-actions[bot] f8fe1e3e22 🆙 Bump version to 4.2.25 [skip ci] 2026-05-28 14:37:58 +00:00
DuckieTM be77cdf4aa Merge pull request #135 from duckietm/dev
Dev
2026-05-28 16:36:43 +02:00
duckietm 1ba2e43d4d 🆙 Wheel updates 2026-05-28 16:36:22 +02:00
medievalshell 8dd5155562 feat: persist scale for room ads / branding furni
InteractionRoomAds now carries a `scale` default value (100) alongside
imageUrl/clickUrl/offsetX/Y/Z, so the image zoom set in the client's
position editor is stored and broadcast like the other branding fields.
2026-05-28 15:30:33 +02:00
DuckieTM 4f4f581371 Merge pull request #129 from medievalshell/Dev
feat: rare values + fortune wheel + in-client prize editor + feat: soundboard (room-scoped custom audio pads) + feat: version string tied to project version + "Extended" title
2026-05-28 13:50:52 +02:00
duckietm 9705b3e42a 🆕 Added the option turn in menu for BOT 2026-05-28 13:00:02 +02:00
medievalshell e626a7fc50 feat: version string tied to project version + "Extended" title
The :about / :info hotel-info title was hardcoded ("Arcturus Morningstar
4.1.0") and drifted from the real build. Now Emulator.version reads the
jar manifest's Implementation-Version (= ${project.version}, added via the
assembly plugin) and falls back to MAJOR.MINOR.BUILD only outside a jar.
Title becomes "Arcturus Morningstar Extended <version>" (e.g. 4.2.24).
2026-05-28 12:33:50 +02:00
Medievalshell d6ebb632e6 Merge branch 'duckietm:main' into Dev 2026-05-28 12:11:27 +02:00
github-actions[bot] 014ca9ca48 🆙 Bump version to 4.2.24 [skip ci] 2026-05-28 09:50:45 +00:00
DuckieTM d189d66f9e Merge pull request #133 from duckietm/dev
🆙 Update effects
2026-05-28 11:49:38 +02:00
duckietm c272a36cc5 🆙 Update effects 2026-05-28 11:49:20 +02:00
github-actions[bot] 1d6e05ee57 🆙 Bump version to 4.2.23 [skip ci] 2026-05-28 09:35:48 +00:00
DuckieTM ea44771d69 Merge pull request #132 from duckietm/dev
Update 007_Frank.sql
2026-05-28 11:34:37 +02:00
duckietm 1da783aff9 Update 007_Frank.sql 2026-05-28 11:34:19 +02:00
github-actions[bot] e772686c4b 🆙 Bump version to 4.2.22 [skip ci] 2026-05-28 09:05:33 +00:00
DuckieTM a00f7b01f5 Merge pull request #130 from duckietm/dev
Dev
2026-05-28 11:04:35 +02:00
duckietm 6b4089cace 🆙 small typo in SQL 2026-05-28 11:04:01 +02:00
duckietm 9ea7acf05c 🆙 Update for Frank 2026-05-28 10:53:50 +02:00
duckietm bab43af41e 🆕 Frank the BOT
-- VERY IMPORTANT !!!!
-- First check if the items_base ID and catalog_items ID is not in use !
-- After the SQL please go to the catalog_items table and change the page_id to where your BOTS are located
2026-05-28 10:41:25 +02:00
medievalshell 10a2b2b872 feat: soundboard (room-scoped custom audio pads)
Server side of the soundboard feature:
- rooms.soundboard_enabled flag + soundboard_sounds table (self-bootstraps
  at boot via SoundboardManager; migration 021 seeds up-front)
- SoundboardManager loads enabled sounds and persists the per-room flag
- SoundboardPlayEvent broadcasts the pressed pad to everyone in the room
- SoundboardSetEnabledEvent (owner/staff) toggles the room flag and
  pushes refreshed settings
- settings (flag + sound list) sent on room enter, alongside YouTube
2026-05-28 09:03:27 +02:00
medievalshell 458b37dbed feat: rare values + fortune wheel + in-client prize editor
Catalog-derived rare value map (diamond-priced), fortune wheel (WheelManager,
weighted RNG, lazy daily reset, rewards, recent wins) + admin prize editor
gated on acc_supporttool. Packets 9300-9305 / 9400-9404. Migration 020.
2026-05-28 02:39:01 +02:00
github-actions[bot] 55b38e7b85 🆙 Bump version to 4.2.21 [skip ci] 2026-05-27 13:39:01 +00:00
DuckieTM 4a96c5baaf Merge pull request #128 from duckietm/dev
Dev
2026-05-27 15:37:57 +02:00
duckietm 539c5b5b96 🆙 Fix BOTS in catalog and inventory 2026-05-27 13:46:17 +02:00
duckietm 7b7154e68f 🆙 Fix search and buy #1 2026-05-27 11:34:55 +02:00
duckietm 4aabb738a3 🆙 Added missing Table for the HK 2026-05-27 09:47:30 +02:00
github-actions[bot] 691dc42627 🆙 Bump version to 4.2.20 [skip ci] 2026-05-27 07:43:14 +00:00
DuckieTM 226873c1fb Merge pull request #127 from duckietm/dev
Dev
2026-05-27 09:42:21 +02:00
duckietm a06a204b39 Merge branch 'dev' of https://github.com/duckietm/Arcturus-Morningstar-Extended into dev 2026-05-27 09:37:51 +02:00
duckietm e213609609 🆕 Added Pickup furni to the floorplan 2026-05-27 09:37:49 +02:00
DuckieTM 44d38b8661 🆙 SQL update 2026-05-26 22:18:02 +02:00
github-actions[bot] ccadb81970 🆙 Bump version to 4.2.19 [skip ci] 2026-05-26 15:16:04 +00:00
DuckieTM 0a3a940946 Merge pull request #125 from duckietm/dev
🆙 Small fix floorplan
2026-05-26 17:15:07 +02:00
duckietm 4613fbe80c 🆙 Small fix floorplan 2026-05-26 17:14:49 +02:00
github-actions[bot] 9328f4a355 🆙 Bump version to 4.2.18 [skip ci] 2026-05-26 14:37:17 +00:00
DuckieTM da8b947ddf Merge pull request #124 from duckietm/dev
🆕 Brand new Floorplan
2026-05-26 16:36:17 +02:00
duckietm b9658d0407 🆕 Brand new Floorplan 2026-05-26 16:35:58 +02:00
DuckieTM 68d3731393 Merge pull request #123 from duckietm/dev
Dev
2026-05-26 12:53:07 +02:00
duckietm 4ef4ed1a96 🆙 Enable HK in client with permissions 2026-05-26 12:52:49 +02:00
DuckieTM c20e273a2c Merge pull request #120 from simoleo89/feat/housekeeping-packets
feat(housekeeping): in-client admin packet handlers
2026-05-26 10:44:13 +02:00
github-actions[bot] 83d418e712 🆙 Bump version to 4.2.17 [skip ci] 2026-05-26 08:04:04 +00:00
DuckieTM 38d05e8a06 Merge pull request #122 from duckietm/dev
Dev
2026-05-26 10:03:05 +02:00
duckietm c83a3bad8e 🆙 Gift Updates 2026-05-26 09:58:22 +02:00
DuckieTM 0bd23ad244 Merge pull request #121 from Lorenzune/merge-duckie-main-2026-05-06
Improve emulator monitoring and wired stability safeguards
2026-05-25 18:41:50 +02:00
Lorenzune d8f74b2477 Improve emulator monitoring and wired stability safeguards 2026-05-25 10:10:57 +02:00
simoleo89 dac09e92d1 fix(housekeeping): hash reset password with BCrypt, not SHA-256
`HousekeepingResetUserPasswordEvent` was writing a SHA-256 hex digest
into `users.password`, but the Nitro auth path
(`SessionEndpoints` / `AccountChangeEndpoints` → `AuthHttpUtil.checkPassword`)
only does `BCrypt.checkpw`. A SHA-256 hex string doesn't start with
`$2…$`, so jbcrypt throws `IllegalArgumentException`, `checkPassword`
returns false, and operators saw "credenziali invalide" on every
account whose password had been reset from the in-client panel.

Switch to `BCrypt.hashpw(plain, BCrypt.gensalt(10))` — same idiom
already used by `SessionEndpoints.java:351` and
`AccountChangeEndpoints.java:98`. Cost 10 (vs 12 there) is fine for a
server-generated 12-char random password: gensalt(10) keeps the
operator-facing reset snappy and the output is identical-shape
(`$2a$…`) to what jbcrypt 0.4 already accepts.

Side-effects:
- drops the `MessageDigest` / `NoSuchAlgorithmException` /
  `StandardCharsets` imports and the local `sha256Hex` helper
- repurposes the existing `housekeeping.error.hash_failed` key for
  `BCrypt.gensalt`'s only failure mode (invalid cost / log_rounds out
  of range) so the client error surface is unchanged
- updates the file javadoc to stop telling future readers to "swap the
  MessageDigest constant" — Arcturus itself only verifies BCrypt

Companion of duckietm/Nitro-V3#157 (`feat/housekeeping-panel`). The
client/UI is untouched — packet 9200, the action-result reveal card,
the copy button, and the plaintext flow through `message` are all
unchanged.
2026-05-24 22:25:16 +02:00
simoleo89 fbf979419e feat(housekeeping): hotel alert + dashboard + audit log
Closes out the HK panel server-side surface.

* Incoming 9127 HousekeepingSendHotelAlertEvent — broadcast a
  StaffAlertWithLinkComposer to every online user that hasn't
  set blockStaffAlerts. Composed once, fanned out by reference;
  empty-message guard returns `housekeeping.error.alert_empty`.

* Outgoing 9206 HousekeepingDashboardComposer + Incoming 9128
  HousekeepingGetDashboardEvent — single round trip with the
  aggregated counters: online / total users + active / total
  rooms + pending support tickets + sanctions in the last 24h +
  approximate emulator uptime + a version string. Active-rooms
  is derived from RoomManager.getActiveRooms().getUserCount()>0
  to avoid counting idle preloaded rooms. Peak online today /
  all-time aren't tracked yet, so they currently echo the live
  online count as a best-effort placeholder.

* Outgoing 9207 HousekeepingActionLogComposer + Incoming 9129
  HousekeepingListActionLogEvent — read the optional
  housekeeping_log table. If the table isn't there the SQL
  exception is swallowed and an empty list goes back, so the
  panel renders a no-entries view rather than crashing. Schema
  is documented in the handler's javadoc; operators who want
  audit run a single CREATE TABLE then the HK panel populates
  from new writes (writes are a follow-up — every HK handler
  will eventually append a row).

`mvn package` clean — the final fat jar lands in
Latest_Compiled_Version/ after the build finishes.
2026-05-24 16:31:01 +02:00
simoleo89 6126c35779 feat(housekeeping): economy domain — credits/currency/items/hc
* Incoming 9117 HousekeepingGiveCreditsEvent — Habbo.giveCredits for
  online (ships UserCreditsComposer) or UPDATE users.credits for offline.

* Incoming 9118 HousekeepingGiveCurrencyEvent — generic across the
  non-credits currencies. currencyType 0 => duckets/pixels (givePixels),
  5 => diamonds (givePoints(5,n)), anything else routes through
  givePoints(type,n). Offline path INSERT ... ON DUPLICATE KEY UPDATE
  users_currency.

* Incoming 9119 HousekeepingGrantItemEvent — batch-INSERT N rows into
  the items table with item_id = base furni id. Capped at 100 per call
  so a typo can't bury the DB. Online inventory refresh deferred — the
  user picks the new items up on next hand-inventory open or relog.

* Incoming 9120 HousekeepingSetHcSubscriptionEvent — extends
  users_settings.club_expire_timestamp by `days*86400`. Stacks on top
  of the existing expiry if it's still in the future, otherwise starts
  from now. days==0 clamps to now (effective cancel).

All four reuse HousekeepingActionResultComposer (no new outgoing
composer this slice).

`mvn compile` clean.
2026-05-24 16:29:55 +02:00
simoleo89 a1749c9eda feat(housekeeping): rooms domain — find/search + open/close/mute/kick-all/transfer/delete
Eight new incoming handlers + two new outgoing composers cover the
full rooms-domain HK panel.

* Outgoing 9202 HousekeepingRoomDetailComposer — single room with a
  leading `found` boolean. Writes the IHousekeepingRoom shape via a
  static `appendRoomFields` that HousekeepingRoomListComposer shares.

* Outgoing 9203 HousekeepingRoomListComposer — `count` then N rooms.
  Used for both find-by-name (exact match, up to 50) and the prefix
  autocomplete dropdown (up to 8).

* Incoming 9110 HousekeepingFindRoomByIdEvent — loadRoom(id, false)
  covers both the in-memory cache and the offline `SELECT * FROM rooms`
  path. No `loadData` so HK doesn't pull furni/bots/pets just to
  render a summary.

* Incoming 9111 HousekeepingSearchRoomsEvent — (query, exactMatch,
  limit). Branches between `name = ?` and `name LIKE ?` so the same
  wire packet serves both the autocomplete and the exact-find flows.
  Hard-capped to 50.

* Incoming 9112 HousekeepingRoomStateEvent — (roomId, open). Toggles
  Room.setState(OPEN | LOCKED) and persists via Room.save(). One
  packet covers both the open and close API endpoints.

* Incoming 9113 HousekeepingMuteRoomEvent — (roomId, minutes). Room.
  setMuted is a boolean, so minutes==0 unmutes and minutes>0 mutes.
  A scheduled auto-unmute is left for a future slice; the wire field
  is reserved.

* Incoming 9114 HousekeepingKickAllFromRoomEvent — Room.ejectAll().

* Incoming 9115 HousekeepingTransferRoomOwnershipEvent — UPDATEs both
  rooms.owner_id and rooms.owner_name so the navigator cached name
  doesn't go stale. Validates the new owner exists via
  HabboManager.getHabboInfo before touching the row.

* Incoming 9116 HousekeepingDeleteRoomEvent — ejectAll + dispose +
  uncacheRoom + DELETE FROM rooms, mirroring the minimum-viable
  subset of RequestDeleteRoomEvent. Pets/guild/custom-layout cleanup
  is skipped on this slice (orphans don't crash the emulator).

`mvn compile` clean.
2026-05-24 16:29:55 +02:00
simoleo89 525c124fa5 feat(housekeeping): set-rank + trade-lock + reset-password
Closes out the users-domain HK actions.

* Incoming 9107 HousekeepingSetUserRankEvent — (userId, rankId).
  Validates the rank exists in `permission_ranks`, UPDATEs users.rank,
  and if the target is online rebinds their HabboInfo to the fresh
  Rank object and ships a UserPermissionsComposer so server-side
  hasPermission() and the client's useHasPermission(key) consumers
  re-render against the new permissions without a relog.

* Incoming 9108 HousekeepingTradeLockUserEvent — (userId, hours,
  reason). Writes `users_settings.trade_locked_until = now + hours*3600`
  so the lock survives logout/login. Online targets also get their
  in-memory HabboStats.allowTrade cleared and an optional alert.

* Incoming 9109 HousekeepingResetUserPasswordEvent — (userId).
  Generates a 12-char alphanumeric (SecureRandom over a curated
  ambiguity-free alphabet), writes its SHA-256 hex to users.password
  (the column is varchar(64) — already sized for SHA-256 hex) and
  blanks auth_ticket so any live SSO ticket can't bypass the reset.
  Plaintext is returned to the operator in the action-result
  message — they relay it out-of-band. If your CMS uses a hash other
  than SHA-256, swap the MessageDigest.getInstance constant.

`mvn compile` clean.
2026-05-24 16:29:55 +02:00
simoleo89 57087a31f2 fix(housekeeping): emit localizable error keys instead of bare slugs
Every HK action handler returned bare error slugs (\"invalid_input\",
\"user_offline\", \"no_active_ban\", \"target_unkickable\", \"ban_failed\",
\"user_not_found\") in HousekeepingActionResultComposer.message. The
client's `localizeOrPassthrough` only treats a value as a translation
key when it contains a dot, so those bare slugs were rendered raw in
the status banner and the toast — ugly and untranslatable.

Re-prefix all error messages with `housekeeping.error.` so the EN +
IT dictionaries can resolve them. Success path is unchanged (server
sends empty string, client falls back to `housekeeping.action.success`).

Companion dictionary entries land on the client side.
2026-05-24 16:29:55 +02:00
simoleo89 c4b3295a45 feat(housekeeping): force-disconnect-user packet
Incoming 9106 HousekeepingForceDisconnectUserEvent — (userId, reason).
Sends the optional reason as a Habbo.alert, dispatches the action ack
BEFORE calling target.disconnect() so the result lands on the wire
before the target's socket closes, then drops the session. Online-only;
offline target returns `user_offline`.

`mvn compile` clean.
2026-05-24 16:29:54 +02:00
simoleo89 418c753e6c feat(housekeeping): mute-user + kick-user packets
Incoming 9104 HousekeepingMuteUserEvent — (userId, reason, minutes).
Unlike ModToolSanctionMute which takes a fixed-bucket minutes arg
from a CFH context, this one applies an arbitrary in-session mute via
Habbo.mute(seconds, false). Mute is online-only (the live Habbo object
holds the remaining seconds), so an offline target returns ok=false
with `user_offline`. The reason string, if non-empty, is delivered via
Habbo.alert so the muted user sees why.

Incoming 9105 HousekeepingKickUserEvent — (userId, reason). Replicates
the ModToolManager.kick body (leave room + alert) locally so HK doesn't
piggyback on ACC_SUPPORTTOOL the way ModToolManager.kick does — keeps
the permission model `acc_housekeeping`-only. Respects ACC_UNKICKABLE
the same way the legacy path does.

Both reuse HousekeepingActionResultComposer with their own actionKey
(user.mute / user.kick).

`mvn compile` clean.
2026-05-24 16:29:54 +02:00
simoleo89 8419f11883 feat(housekeeping): unban-user packet
Incoming 9103 HousekeepingUnbanUserEvent — reads userId, resolves
the username via HabboManager.getHabboInfo(int) (covers both online
and offline paths in one call), then dispatches to
ModToolManager.unban(username) which clears all active rows from
the `bans` table for that user.

Reuses HousekeepingActionResultComposer with actionKey `user.unban`.
If the user never had an active ban the SQL UPDATE matches zero rows
and the handler responds with `ok: false, message: 'no_active_ban'`
— from a UI standpoint that's a no-op, not an error.

`mvn compile` clean.
2026-05-24 16:29:54 +02:00
simoleo89 1a0d783ff7 feat(housekeeping): ban-user with arbitrary duration + ack composer
Adds two new packets:

* Incoming 9102 HousekeepingBanUserEvent — reads (userId, reason,
  hours). Unlike ModToolSanctionBanEvent which only accepts the four
  fixed Habbo-protocol banType buckets (18h / 7d / 30d / 100y), this
  one converts the hours arg straight to seconds and feeds them into
  ModToolManager.ban with ModToolBanType.ACCOUNT and cfhTopic=0.
  Duration is clamped to 100 years to keep it inside `int` range.

* Outgoing 9201 HousekeepingActionResultComposer — generic ack
  for any HK action (ban / mute / kick / give-credits / room-close /
  …). Wire shape is (actionKey, ok, actionId, message). The
  actionKey lets the client filter multiple in-flight actions to
  the right Promise via `accept`, so concurrent admin operations
  don't cross-resolve.

actionId here is the target user id because ModToolBan doesn't
expose the `bans` autoinc id on the object — there's a TODO to swap
this for a dedicated housekeeping_log row id once that table goes in.

Same ACC_HOUSEKEEPING permission gate as the find-user packets, so
operators only need to grant the permission once.

`mvn compile` clean.
2026-05-24 16:29:54 +02:00
simoleo89 655e039df7 feat(housekeeping): find-user-by-id packet + acc_housekeeping gate
Adds Incoming 9101 HousekeepingFindUserByIdEvent, which replies on
the existing HousekeepingUserDetailComposer (Outgoing 9200) — the
composer is shape-agnostic about how the lookup was issued, so the
two find-* handlers share the same response packet.

The by-id handler uses HabboManager.getHabboInfo(int) directly, which
already covers both the online (in-memory hashmap) and offline (SQL
LIMIT 1 on users) branches in one call. The by-name path still has
to do online + offline manually because the equivalent String overload
doesn't exist as an instance method, only as a static.

Also introduces Permission.ACC_HOUSEKEEPING ("acc_housekeeping") so
the in-client housekeeping panel doesn't piggyback on ACC_SUPPORTTOOL.
Both HK handlers now gate on the new permission; the toolbar UI on
the client side was already checking `acc_housekeeping`, so this
closes the loop. Operators must add the permission to
permission_definitions for the desired rank:

  INSERT INTO permission_definitions
    (permission_key, max_value, comment,
     rank_1, rank_2, rank_3, rank_4, rank_5, rank_6, rank_7)
  VALUES
    ('acc_housekeeping', 1,
     'Allows access to the in-client Housekeeping admin panel ...',
     0, 0, 0, 0, 0, 0, 1)
  ON DUPLICATE KEY UPDATE rank_7 = 1, comment = VALUES(comment);

`mvn package` clean (Habbo-4.2.12-jar-with-dependencies.jar).
2026-05-24 16:29:54 +02:00
simoleo89 7726691cde feat(housekeeping): add find-user-by-name packet pair
First wire-level packet for the in-client housekeeping admin panel.
Adds an incoming handler (Incoming 9100) that resolves a username to
a HabboInfo (online via the HabboManager hashmap, offline via the
users-table SQL fallback) and replies with HousekeepingUserDetail
(Outgoing 9200) containing id / username / motto / look / rank / rank
name / online / lastOnline / credits / duckets / diamonds / email /
ipLogin / isBanned. Active-mute and active-trade-lock are written as
trailing booleans (currently false) so the renderer parser can pick
them up later behind a bytesAvailable guard once those manager APIs
are surfaced offline-side.

Permission gate is ACC_SUPPORTTOOL — same one ModTools already uses.
Avoids adding a new column to the permissions table on this slice; a
dedicated ACC_HOUSEKEEPING permission can be introduced later when
the destructive HK operations (give-credits, delete-room, reset-pwd)
go in.

Reserves the 9100..9199 / 9200..9299 ID blocks for the rest of the
HK packet surface (search-prefix, find-by-id, ban/mute/kick with
arbitrary duration, room actions, economy, catalog admin, dashboard
aggregate, audit log read).

`mvn compile` clean on Habbo 4.2.12.
2026-05-24 16:29:54 +02:00
github-actions[bot] 67503aeb2a 🆙 Bump version to 4.2.16 [skip ci] 2026-05-22 09:04:30 +00:00
DuckieTM b206b32748 Merge pull request #119 from duckietm/dev
🆙 Catalog Editor, now you can also edit the text1
2026-05-22 11:03:35 +02:00
duckietm ad60861a3f 🆙 Catalog Editor, now you can also edit the text1 2026-05-22 11:03:17 +02:00
github-actions[bot] b77290f5e7 🆙 Bump version to 4.2.15 [skip ci] 2026-05-21 15:03:23 +00:00
DuckieTM b14730d37f Merge pull request #118 from duckietm/dev
Dev
2026-05-21 17:02:19 +02:00
duckietm 9126396973 🆙 Fix Catalog Edit 2026-05-21 17:01:56 +02:00
duckietm d321ff3b85 Update 003_live_required_schema.sql 2026-05-21 15:54:10 +02:00
duckietm 7f38a25eef 🆙 Small SQL update 2026-05-21 15:44:30 +02:00
269 changed files with 12280 additions and 1767 deletions
@@ -23,6 +23,10 @@ SET NAMES utf8mb4;
ALTER TABLE `emulator_settings`
ADD COLUMN IF NOT EXISTS `comment` TEXT NULL DEFAULT '' AFTER `value`;
ALTER TABLE catalog_pages
ADD COLUMN IF NOT EXISTS `catalog_mode` ENUM('NORMAL', 'BUILDER', 'BOTH') NOT NULL DEFAULT 'NORMAL' AFTER `includes`;
CREATE TABLE IF NOT EXISTS `wired_emulator_settings` (
`key` VARCHAR(255) NOT NULL,
`value` TEXT NOT NULL,
@@ -318,13 +322,6 @@ CREATE TABLE IF NOT EXISTS `custom_prefix_settings` (
PRIMARY KEY (`key_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `custom_prefix_blacklist` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`word` VARCHAR(100) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_custom_prefix_blacklist_word` (`word`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO `custom_prefix_settings` (`key_name`, `value`) VALUES
('max_length', '15'),
('min_rank_to_buy', '1'),
@@ -0,0 +1,17 @@
INSERT INTO `permission_definitions` (`permission_key`, `max_value`, `comment`, `rank_1`, `rank_2`, `rank_3`, `rank_4`, `rank_5`, `rank_6`, `rank_7`) VALUES ('acc_housekeeping', '1', 'Allow housekeeping in the client', '0', '0', '0', '0', '0', '0', '1');
CREATE TABLE IF NOT EXISTS `housekeeping_log` (
`id` INT NOT NULL AUTO_INCREMENT,
`timestamp` INT NOT NULL,
`actor_id` INT NOT NULL,
`actor_name` VARCHAR(64) NOT NULL DEFAULT '',
`target_type` VARCHAR(16) NOT NULL DEFAULT 'user',
`target_id` INT NOT NULL DEFAULT 0,
`target_label` VARCHAR(128) NOT NULL DEFAULT '',
`action` VARCHAR(64) NOT NULL DEFAULT '',
`detail` VARCHAR(500) NOT NULL DEFAULT '',
`success` TINYINT NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
KEY `timestamp` (`timestamp`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+70
View File
@@ -0,0 +1,70 @@
ALTER TABLE `bots`
MODIFY COLUMN `type` ENUM('generic','visitor_log','bartender','weapons_dealer','frank')
NOT NULL DEFAULT 'generic';
INSERT INTO `permission_definitions`
(`permission_key`, `max_value`, `comment`,
`rank_1`, `rank_2`, `rank_3`, `rank_4`, `rank_5`, `rank_6`, `rank_7`)
VALUES
('acc_bot_frank', 1, 'Required to purchase the Frank mascot bot from the catalog.',
0, 0, 0, 0, 0, 0, 1)
ON DUPLICATE KEY UPDATE `comment` = VALUES(`comment`);
CREATE TABLE IF NOT EXISTS `bot_chat_responses` (
`id` INT NOT NULL AUTO_INCREMENT,
`bot_type` VARCHAR(32) NOT NULL,
`keys` VARCHAR(255) NOT NULL COMMENT 'semicolon-separated trigger words',
`responses` TEXT NOT NULL COMMENT 'newline-separated replies; bot picks one at random',
PRIMARY KEY (`id`),
KEY `bot_type` (`bot_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `bot_chat_responses` (`bot_type`, `keys`, `responses`) VALUES
('frank', '__door_triggers', 'show me the door\nkick me\ni want to leave\nlet me out'),
('frank', '__door_lines', 'Right this way - mind the step!\nAnd out you go. Come back soon!\nAllow me to escort you to the exit.\nThere''s the door. Farewell, true believer!'),
('frank', '__busy_whisper', 'Sorry, I am currently busy. Please wait until I am available.'),
('frank', 'frank', 'Hello, I''m Frank! Welcome to Habbo.'),
('frank', 'help', 'What do you need help with?'),
('frank', 'thanks;thank you', 'Just doing my job, true believer!'),
('frank', 'new', 'Welcome to Habbo! I hope you have a great time here.'),
('frank', 'rooms', 'Looking for somewhere fun? Try the Navigator - thousands of rooms to explore!'),
('frank', 'sulake', 'Sulake is the company behind Habbo. Take a look: https://www.sulake.com'),
('frank', 'vip;hc', 'VIP gets you more outfits, more furni, more everything. Worth it!'),
('frank', 'music', 'Snoop Dogg, Frank Sinatra and a little Beethoven on Sundays.'),
('frank', 'movie', 'I''m a Casablanca man. Black and white films are an underrated art.'),
('frank', 'game', 'Battleship. Always Battleship.'),
('frank', 'snowstorm', 'Honestly? I''m terrible at Snowstorm. Don''t tell anyone.'),
('frank', 'furni', 'Best furniture maker in town - hands down, the folks at Sulake.'),
('frank', 'animal;cat;pet','I have a cat called Mr. Whiskers. He runs the place, really.'),
('frank', 'miranda', 'Miranda. The love of my life. Don''t get me started.'),
('frank', 'frank black', 'Named after the man himself. Frank Black is a hero of mine.'),
('frank', 'life', 'Life is like a bowl of popcorn - warm, salty and buttery.'),
('frank', 'job;work', 'I''m sure you can find work in one of the guest rooms!'),
('frank', 'snouthill', 'Snouthill... so many memories.'),
('frank', 'wife', 'I had a wife once. She broke my stereo.'),
('frank', 'baseball', 'Oh, I used to love to go down to the old ball park and watch Christy Mathewson and Honus Wagner at bat.'),
('frank', 'mark', 'I don''t trust Mark.'),
('frank', 'vietnam', 'Vietnam? Don''t ask. Worst trip of my life.'),
('frank', 'pills;drugs', 'Drugs are bad, mmkay?');
INSERT IGNORE INTO `bot_serves` (`keys`, `item`) VALUES
('sunflower', 1002),
('cola;habbo cola', 19),
('rose', 1000),
('book', 1003),
('tea', 27),
('coffee', 8),
('migraine;headache;pills', 1015),
('radioactive liquid;radioactive', 30),
('turkey;can of turkey', 70);
-- VERY IMPORTANT !!!!
-- First check if the items_base ID and catalog_items ID is not in use !
-- After the SQL please go to the catalog_items table and change the page_id to where your BOTS are located
INSERT IGNORE INTO `items_base` (`id`, `sprite_id`, `item_name`, `public_name`, `width`, `length`, `stack_height`, `allow_stack`, `allow_sit`, `allow_lay`, `allow_walk`, `allow_gift`, `allow_trade`, `allow_recycle`, `allow_marketplace_sell`, `allow_inventory_stack`, `type`, `interaction_type`, `interaction_modes_count`, `vending_ids`, `multiheight`, `customparams`)
VALUES (19001, 0, 'bot_frank', 'Frank', 1, 1, 0.00, '0', '0', '0', '1', '0', '0', '0', '0', '0', 'r', 'default', 1, '0', '0', '0');
INSERT IGNORE INTO `catalog_items` (`item_ids`, `page_id`, `offer_id`, `catalog_name`, `cost_credits`, `cost_points`, `points_type`, `amount`, `extradata`)
VALUES ('19001', 8, 19001, 'Frank', 0, 0, 0, 1, 'name:Frank;motto:Welcome to Habbo!;figure:hr-3499-33.sh-290-90.ch-3971-72-73.lg-270-73.hd-205-1-1.fa-1206-67.ha-3409-73-72;gender:m');
@@ -0,0 +1,89 @@
ALTER TABLE `rooms`
ADD COLUMN IF NOT EXISTS `soundboard_enabled` TINYINT(1) NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS `soundboard_sounds` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`name` VARCHAR(64) NOT NULL DEFAULT '', -- pad label shown in the client
`url` VARCHAR(255) NOT NULL DEFAULT '', -- audio url (uploaded via CMS, like custom badges)
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`sort_order` INT(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ----------------------------------------------------------------------------
-- Fortune Wheel — tables
-- ----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `wheel_prizes` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`type` VARCHAR(16) NOT NULL DEFAULT 'nothing', -- item | badge | credits | points | spin | nothing
`value` VARCHAR(64) NOT NULL DEFAULT '', -- item: base item id ; badge: badge code ; others: unused
`amount` INT(11) NOT NULL DEFAULT 1, -- item qty / credits / points / extra spins
`points_type` INT(11) NOT NULL DEFAULT 5, -- for type=points (diamond default 5)
`weight` INT(11) NOT NULL DEFAULT 1, -- relative probability
`label` VARCHAR(64) NOT NULL DEFAULT '', -- slice label override (optional)
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`sort_order` INT(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `wheel_user_state` (
`user_id` INT(11) NOT NULL,
`free_spins` INT(11) NOT NULL DEFAULT 0, -- remaining free spins for the current day
`extra_spins` INT(11) NOT NULL DEFAULT 0, -- bought / won spins
`last_reset` INT(11) NOT NULL DEFAULT 0, -- day index of last daily reset (unix / 86400)
PRIMARY KEY (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `wheel_recent_wins` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`user_id` INT(11) NOT NULL,
`username` VARCHAR(64) NOT NULL DEFAULT '',
`look` VARCHAR(255) NOT NULL DEFAULT '',
`prize_label` VARCHAR(64) NOT NULL DEFAULT '',
`won_at` INT(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO `emulator_settings` (`key`, `value`, `comment`) VALUES
('wheel.free_spins_per_day', '1', 'Fortune wheel: free spins granted each day.'),
('wheel.spin_cost', '50', 'Fortune wheel: cost of one extra spin.'),
('wheel.spin_cost_type', '5', 'Fortune wheel: currency type for the spin cost (5 = diamonds; -1 = credits).')
ON DUPLICATE KEY UPDATE `comment` = VALUES(`comment`);
INSERT INTO `wheel_prizes` (`type`, `amount`, `points_type`, `weight`, `label`, `sort_order`)
SELECT `type`, `amount`, `points_type`, `weight`, `label`, `sort_order`
FROM (
SELECT 'points' AS `type`, 25 AS `amount`, 5 AS `points_type`, 20 AS `weight`, '25 diamonds' AS `label`, 1 AS `sort_order`
UNION ALL SELECT 'points', 50, 5, 12, '50 diamonds', 2
UNION ALL SELECT 'points', 200, 5, 3, '200 diamonds', 3
UNION ALL SELECT 'credits', 100, 0, 15, '100 credits', 4
UNION ALL SELECT 'spin', 1, 0, 15, '1 Extra spin', 5
UNION ALL SELECT 'spin', 2, 0, 6, '2 Extra spins', 6
UNION ALL SELECT 'nothing', 0, 0, 29, 'Oh to bad!', 7
) AS seed
WHERE NOT EXISTS (SELECT 1 FROM `wheel_prizes`);
INSERT IGNORE INTO `permission_definitions` (`permission_key`, `max_value`, `comment`)
VALUES (
'acc_wheeladmin',
1,
'Allows opening the Fortune Wheel prize editor (FortuneWheelSettingsView) to add/edit prize slices. Gated server-side by the same key.'
);
SET @cols := NULL;
SELECT GROUP_CONCAT(CONCAT('dst.`', `column_name`, '` = src.`', `column_name`, '`') SEPARATOR ', ')
INTO @cols
FROM `information_schema`.`columns`
WHERE `table_schema` = DATABASE()
AND `table_name` = 'permission_definitions'
AND `column_name` REGEXP '^rank_[0-9]+$';
SET @sql := CONCAT(
'UPDATE `permission_definitions` dst ',
'JOIN `permission_definitions` src ON src.`permission_key` = ''acc_ads_background'' ',
'SET ', @cols, ' ',
'WHERE dst.`permission_key` = ''acc_wheeladmin'''
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
@@ -0,0 +1,89 @@
CREATE TABLE IF NOT EXISTS `habbo_mentions` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`target_user_id` INT(11) NOT NULL,
`sender_user_id` INT(11) NOT NULL,
`sender_username` VARCHAR(64) NOT NULL DEFAULT '',
`room_id` INT(11) NOT NULL DEFAULT 0,
`room_name` VARCHAR(64) NOT NULL DEFAULT '',
`message` VARCHAR(255) NOT NULL DEFAULT '',
`mention_type` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '0 = direct (@nick), 1 = broadcast (@all/@friends/@room)',
`timestamp` INT(11) NOT NULL DEFAULT 0,
`read` TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_target_id` (`target_user_id`, `id`),
KEY `idx_target_unread` (`target_user_id`, `read`),
KEY `idx_target_timestamp` (`target_user_id`, `timestamp`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `permission_definitions`
(`permission_key`, `max_value`, `comment`,
`rank_1`, `rank_2`, `rank_3`, `rank_4`, `rank_5`, `rank_6`, `rank_7`, `rank_8`)
VALUES
('acc_mention_everyone', 1,
'Allow sending @all / @everyone / @tutti broadcast mentions (hotel-wide).',
0, 0, 0, 0, 1, 1, 1, 1),
('acc_mention_friends', 1,
'Allow sending @friends / @amici broadcast mentions (sender''s online buddies).',
0, 0, 0, 0, 1, 1, 1, 1),
('cmd_disablementions', 1,
'Allow toggling :disablementions to stop receiving any @mention notifications.',
1, 1, 1, 1, 1, 1, 1, 1),
('cmd_disablemassmentions', 1,
'Allow toggling :disablemassmentions to stop receiving broadcast mentions (direct @nick still works).',
1, 1, 1, 1, 1, 1, 1, 1)
ON DUPLICATE KEY UPDATE
`comment` = VALUES(`comment`);
-- ----------------------------------------------------------------------------
-- 3. Emulator settings: cooldowns, caps and alias lists
--
-- Only inserted when missing - existing tuned values are preserved.
-- ----------------------------------------------------------------------------
INSERT IGNORE INTO `emulator_settings` (`key`, `value`, `comment`) VALUES
('mentions.enabled', '1',
'Master switch. 1 = process @mentions, 0 = disable the feature entirely.'),
('mentions.max.targets', '50',
'Hard cap on how many users a single broadcast (@all / @friends / @room) can fan out to.'),
('mentions.cooldown.ms', '3000',
'Per-sender cooldown between any two mentions, in milliseconds.'),
('mentions.room.cooldown.ms', '15000',
'Extra per-sender cooldown for broadcast mentions (@all / @friends / @room) on top of mentions.cooldown.ms.'),
('mentions.store.limit', '50',
'Number of mentions returned in the initial RequestMentionsList response.'),
('mentions.request.cooldown.ms', '2000',
'Per-user cooldown between RequestMentionsList packets.'),
('mentions.markread.cooldown.ms', '500',
'Per-user cooldown between mark-single-as-read packets.'),
('mentions.markall.cooldown.ms', '5000',
'Per-user cooldown between mark-all-as-read packets (bulk DB update).'),
('mentions.delete.cooldown.ms', '500',
'Per-user cooldown between delete-mention packets.'),
('mentions.everyone.aliases', 'all,everyone,tutti',
'Comma-separated aliases that trigger an @everyone broadcast (requires acc_mention_everyone).'),
('mentions.friends.aliases', 'friends,amici',
'Comma-separated aliases that trigger an @friends broadcast (requires acc_mention_friends).'),
('mentions.room.aliases', 'room,stanza',
'Comma-separated aliases that trigger an @room broadcast (no permission required, room scope only).');
ALTER TABLE `wordfilter`
ADD COLUMN `prefix_only` ENUM('0','1') NOT NULL DEFAULT '0'
COMMENT 'When 1, this word only applies to custom prefixes, not to chat/motto/guild.' AFTER `mute`;
-- ----------------------------------------------------------------------------
-- 5. Per-user mention preferences (:disablementions / :disablemassmentions)
--
-- Read by HabboStats (default '1' = enabled), toggled by the commands.
-- Without these columns the toggle commands cannot persist.
-- ----------------------------------------------------------------------------
ALTER TABLE `users_settings`
ADD COLUMN IF NOT EXISTS `mentions_enabled` ENUM('0','1') NOT NULL DEFAULT '1'
COMMENT 'Receive @nick mention notifications.',
ADD COLUMN IF NOT EXISTS `mass_mentions_enabled` ENUM('0','1') NOT NULL DEFAULT '1'
COMMENT 'Receive broadcast (@all / @friends / @room) mentions.';
@@ -0,0 +1,68 @@
-- 020_furnidata_edit_log.sql
-- Audit trail for furnidata name/description edits made through the furni editor,
-- plus config keys for the editor write path. NOTE: *.enabled keys elsewhere are
-- read via Boolean.parseBoolean (true/false), but these two are numeric.
CREATE TABLE IF NOT EXISTS `furnidata_edit_log` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`classname` varchar(255) NOT NULL,
`action` enum('edit','revert') NOT NULL DEFAULT 'edit',
`old_name` varchar(256) NOT NULL DEFAULT '',
`new_name` varchar(256) NOT NULL DEFAULT '',
`old_description` varchar(256) NOT NULL DEFAULT '',
`new_description` varchar(256) NOT NULL DEFAULT '',
`timestamp` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `idx_classname` (`classname`),
INDEX `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
('items.furnidata.edit.backup.keep','10'),
('items.furnidata.edit.ratelimit.ms','2000'),
-- Server-authoritative furni names (source of truth = furnidata JSON)
('items.furnidata.names.enabled','true'),
('items.furnidata.path',''),
('items.furnidata.max.bytes','67108864'),
-- Live-reload watcher
('items.furnidata.watch.enabled','true'),
('items.furnidata.watch.debounce.ms','750'),
('items.furnidata.watch.min.interval.ms','5000'),
('items.furnidata.delta.cap','500'),
-- Furni editor: import official names/descriptions from Habbo
('furni.editor.import.url','https://www.habbo.com/gamedata/furnidata_json/1'),
('furni.editor.import.cache.ms','600000');
START TRANSACTION;
DROP TEMPORARY TABLE IF EXISTS cleanup_furnidata_settings;
CREATE TEMPORARY TABLE cleanup_furnidata_settings (
`key` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL PRIMARY KEY
);
INSERT INTO cleanup_furnidata_settings (`key`) VALUES
('items.furnidata.names.enabled'),
('items.furnidata.path'),
('items.furnidata.max.bytes'),
('items.furnidata.watch.enabled'),
('items.furnidata.watch.debounce.ms'),
('items.furnidata.watch.min.interval.ms'),
('items.furnidata.delta.cap'),
('furni.editor.import.url'),
('furni.editor.import.cache.ms');
-- Preview rows that will be removed.
SELECT es.`key`, es.`value`
FROM emulator_settings es
JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key`
ORDER BY es.`key`;
DELETE es
FROM emulator_settings es
JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key`;
-- Preview remaining matching rows inside the transaction.
SELECT COUNT(*) AS remaining_furnidata_settings
FROM emulator_settings es
JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key`;
-- Safe default. Change to COMMIT after reviewing the preview.
ROLLBACK;
@@ -0,0 +1,9 @@
-- Navigator search filters - companion to the gameserver fix for the catalog
-- 'Find groups to join!' button (navigator/search/hotel_view/group:).
INSERT IGNORE INTO `navigator_filter` (`key`, `field`, `compare`, `database_query`) VALUES
('anything', 'getName', 'contains', 'SELECT rooms.* FROM rooms WHERE rooms.name LIKE ?'),
('roomname', 'getName', 'contains', 'SELECT rooms.* FROM rooms WHERE rooms.name LIKE ?'),
('owner', 'getOwnerName', 'equals_ignore_case', 'SELECT rooms.* FROM rooms WHERE rooms.owner_name LIKE ?'),
('tag', 'getTags', 'contains', 'SELECT rooms.* FROM rooms WHERE rooms.tags LIKE ?'),
('group', 'getGuildName', 'contains', 'SELECT rooms.* FROM rooms INNER JOIN guilds ON guilds.room_id = rooms.id WHERE guilds.name LIKE ?');
@@ -63,15 +63,6 @@ CREATE TABLE IF NOT EXISTS `custom_prefix_settings` (
PRIMARY KEY (`key_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ------------------------------------------------------------
-- 5. Blacklist table
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `custom_prefix_blacklist` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`word` VARCHAR(100) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_word` (`word`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================================
-- Schema upgrades for existing installations
@@ -296,14 +287,6 @@ INSERT IGNORE INTO `custom_prefixes_catalog`
(2, 'Legend', 'Legend', '#8B5CF6', '', 'discord-neon', '', 15, 0, 1, 2),
(3, 'Staff Pick', 'Staff', '#3B82F6', '*', 'cartoon', '', 20, 0, 1, 3);
-- ============================================================
-- Example blacklist entries
-- ============================================================
INSERT IGNORE INTO `custom_prefix_blacklist` (`word`) VALUES
('admin'),
('staff'),
('mod'),
('owner');
-- ============================================================
-- Notes
@@ -0,0 +1,22 @@
-- 020_furnidata_edit_log.sql
-- Audit trail for furnidata name/description edits made through the furni editor,
-- plus config keys for the editor write path. NOTE: *.enabled keys elsewhere are
-- read via Boolean.parseBoolean (true/false), but these two are numeric.
CREATE TABLE IF NOT EXISTS `furnidata_edit_log` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`classname` varchar(255) NOT NULL,
`action` enum('edit','revert') NOT NULL DEFAULT 'edit',
`old_name` varchar(256) NOT NULL DEFAULT '',
`new_name` varchar(256) NOT NULL DEFAULT '',
`old_description` varchar(256) NOT NULL DEFAULT '',
`new_description` varchar(256) NOT NULL DEFAULT '',
`timestamp` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
INDEX `idx_classname` (`classname`),
INDEX `idx_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO `emulator_settings` (`key`,`value`) VALUES
('items.furnidata.edit.backup.keep','10'),
('items.furnidata.edit.ratelimit.ms','2000');
@@ -0,0 +1,42 @@
-- 021_furnidata_config_cleanup.sql
-- Reverts the emulator_settings rows inserted by 021_furnidata_config.sql.
--
-- Safe default:
-- This script ends with ROLLBACK. Run it once to preview the exact rows, then
-- change the final ROLLBACK to COMMIT only if the preview is correct.
START TRANSACTION;
DROP TEMPORARY TABLE IF EXISTS cleanup_furnidata_settings;
CREATE TEMPORARY TABLE cleanup_furnidata_settings (
`key` VARCHAR(255) NOT NULL PRIMARY KEY
);
INSERT INTO cleanup_furnidata_settings (`key`) VALUES
('items.furnidata.names.enabled'),
('items.furnidata.path'),
('items.furnidata.max.bytes'),
('items.furnidata.watch.enabled'),
('items.furnidata.watch.debounce.ms'),
('items.furnidata.watch.min.interval.ms'),
('items.furnidata.delta.cap'),
('furni.editor.import.url'),
('furni.editor.import.cache.ms');
-- Preview rows that will be removed.
SELECT es.`key`, es.`value`
FROM emulator_settings es
JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key`
ORDER BY es.`key`;
DELETE es
FROM emulator_settings es
JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key`;
-- Preview remaining matching rows inside the transaction.
SELECT COUNT(*) AS remaining_furnidata_settings
FROM emulator_settings es
JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key`;
-- Safe default. Change to COMMIT after reviewing the preview.
ROLLBACK;
@@ -0,0 +1,15 @@
-- Fix NULL room paint columns
--
-- Some legacy/imported rooms have NULL in paper_wall / paper_floor / paper_landscape.
-- The server compares these with .equals("0.0") on room entry, which throws a
-- NullPointerException (RoomManager.openRoom) and prevents the room from loading.
-- This normalizes existing NULL values and re-enforces the NOT NULL DEFAULT '0.0'
-- constraint so it cannot happen again.
UPDATE `rooms` SET `paper_wall` = '0.0' WHERE `paper_wall` IS NULL;
UPDATE `rooms` SET `paper_floor` = '0.0' WHERE `paper_floor` IS NULL;
UPDATE `rooms` SET `paper_landscape` = '0.0' WHERE `paper_landscape` IS NULL;
ALTER TABLE `rooms` MODIFY COLUMN `paper_wall` VARCHAR(50) NOT NULL DEFAULT '0.0';
ALTER TABLE `rooms` MODIFY COLUMN `paper_floor` VARCHAR(50) NOT NULL DEFAULT '0.0';
ALTER TABLE `rooms` MODIFY COLUMN `paper_landscape` VARCHAR(50) NOT NULL DEFAULT '0.0';
+27 -19
View File
@@ -6,7 +6,7 @@
<groupId>com.eu.habbo</groupId>
<artifactId>Habbo</artifactId>
<version>4.2.14</version>
<version>4.2.44</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
@@ -38,6 +38,7 @@
<archive>
<manifest>
<mainClass>com.eu.habbo.Emulator</mainClass>
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
</manifest>
</archive>
</configuration>
@@ -61,6 +62,12 @@
<show>public</show>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
</plugin>
</plugins>
</build>
@@ -76,21 +83,21 @@
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.115.Final</version>
<version>4.2.15.Final</version>
</dependency>
<!-- GSON -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.11.0</version>
<version>2.14.0</version>
</dependency>
<!-- MariaDB Connector/J (native driver for MariaDB) -->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.5.1</version>
<version>3.5.8</version>
<scope>runtime</scope>
</dependency>
@@ -106,7 +113,7 @@
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>6.2.1</version>
<version>7.0.2</version>
<scope>compile</scope>
</dependency>
@@ -114,7 +121,7 @@
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.17.0</version>
<version>3.20.0</version>
<scope>compile</scope>
</dependency>
@@ -130,7 +137,7 @@
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.18.3</version>
<version>1.22.2</version>
<scope>compile</scope>
</dependency>
@@ -138,14 +145,14 @@
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.16</version>
<version>2.0.18</version>
</dependency>
<!-- Logback -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.15</version>
<version>1.5.34</version>
<scope>compile</scope>
</dependency>
@@ -153,14 +160,7 @@
<dependency>
<groupId>org.fusesource.jansi</groupId>
<artifactId>jansi</artifactId>
<version>2.4.1</version>
</dependency>
<!-- Joda Time -->
<dependency>
<groupId>joda-time</groupId>
<artifactId>joda-time</artifactId>
<version>2.13.0</version>
<version>2.4.3</version>
</dependency>
<!-- jBCrypt used by the built-in /api/auth/* HTTP login handler
@@ -171,12 +171,20 @@
<version>0.4</version>
</dependency>
<!-- Jakarta Mail used by the built-in forgot-password endpoint
<!-- Jakarta Mail used by the built-in forgot-password endpoint
when smtp.* keys are configured in emulator_settings -->
<dependency>
<groupId>org.eclipse.angus</groupId>
<artifactId>jakarta.mail</artifactId>
<version>2.0.3</version>
<version>2.0.5</version>
</dependency>
<!-- JUnit Jupiter -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -6,6 +6,7 @@ import ch.qos.logback.core.ConsoleAppender;
import com.eu.habbo.core.*;
import com.eu.habbo.core.consolecommands.ConsoleCommand;
import com.eu.habbo.database.Database;
import com.eu.habbo.gui.EmulatorDashboard;
import com.eu.habbo.habbohotel.GameEnvironment;
import com.eu.habbo.habbohotel.gameclients.SessionResumeManager;
import com.eu.habbo.networking.gameserver.GameServer;
@@ -38,12 +39,23 @@ public final class Emulator {
private static final String OS_NAME = (System.getProperty("os.name") != null ? System.getProperty("os.name") : "Unknown");
private static final String CLASS_PATH = (System.getProperty("java.class.path") != null ? System.getProperty("java.class.path") : "Unknown");
// Fallback version, only used when running outside a packaged jar (e.g. from
// the IDE). In production the version comes from the jar manifest below.
public final static int MAJOR = 4;
public final static int MINOR = 1;
public final static int BUILD = 0;
public final static String PREVIEW = "";
public static final String version = "Arcturus Morningstar" + " " + MAJOR + "." + MINOR + "." + BUILD + " " + PREVIEW;
// Tied to the Maven project version: read from the jar manifest
// (Implementation-Version = ${project.version}, see pom assembly plugin).
private static String resolveVersionNumber() {
String implementation = Emulator.class.getPackage().getImplementationVersion();
if (implementation != null && !implementation.isEmpty()) return implementation;
String fallback = MAJOR + "." + MINOR + "." + BUILD;
return PREVIEW.isEmpty() ? fallback : fallback + " " + PREVIEW;
}
public static final String version = "Arcturus Morningstar Extended " + resolveVersionNumber();
private static final String logo =
"\n" +
"███╗ ███╗ ██████╗ ██████╗ ███╗ ██╗██╗███╗ ██╗ ██████╗ ███████╗████████╗ █████╗ ██████╗ \n" +
@@ -186,6 +198,10 @@ public final class Emulator {
Emulator.isReady = true;
Emulator.timeStarted = getIntUnixTimestamp();
if (Emulator.getConfig().getBoolean("gui.enabled", true)) {
EmulatorDashboard.launch();
}
if (Emulator.getConfig().getInt("runtime.threads") < (Runtime.getRuntime().availableProcessors() * 2)) {
LOGGER.warn("Emulator settings runtime.threads ({}) can be increased to ({}) to possibly increase performance.",
Emulator.getConfig().getInt("runtime.threads"),
@@ -79,6 +79,14 @@ class DatabasePool {
databaseConfiguration.addDataSourceProperty("useLocalSessionState", "true");
databaseConfiguration.addDataSourceProperty("cacheResultSetMetadata", "true");
databaseConfiguration.addDataSourceProperty("elideSetAutoCommits", "true");
// Fail fast instead of pinning a pooled connection (and its worker
// thread) indefinitely on a stalled/slow MariaDB. HikariCP's
// connectionTimeout only bounds the pool *borrow*; these bound the
// actual socket/connect round-trip. Overridable via db.params.
databaseConfiguration.addDataSourceProperty("socketTimeout", "30000");
databaseConfiguration.addDataSourceProperty("connectTimeout", "10000");
databaseConfiguration.addDataSourceProperty("tcpKeepAlive", "true");
databaseConfiguration.addDataSourceProperty("maintainTimeStats", "false");
databaseConfiguration.setPoolName("HabboHikariPool");
@@ -0,0 +1,631 @@
package com.eu.habbo.gui;
import com.eu.habbo.Emulator;
import com.eu.habbo.monitoring.EmulatorStatsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.swing.*;
import javax.swing.border.CompoundBorder;
import javax.swing.border.EmptyBorder;
import javax.swing.border.MatteBorder;
import javax.swing.table.DefaultTableCellRenderer;
import javax.swing.table.DefaultTableModel;
import javax.swing.table.JTableHeader;
import java.awt.*;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.geom.Path2D;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class EmulatorDashboard extends JFrame {
private static final Logger LOGGER = LoggerFactory.getLogger(EmulatorDashboard.class);
// Modern Dark Theme Colors
private static final Color COLOR_BG = new Color(18, 18, 18);
private static final Color COLOR_SURFACE = new Color(30, 30, 30);
private static final Color COLOR_SURFACE_HOVER = new Color(45, 45, 45);
private static final Color COLOR_PRIMARY = new Color(99, 102, 241);
private static final Color COLOR_PRIMARY_SOFT = new Color(99, 102, 241, 45);
private static final Color COLOR_SUCCESS = new Color(34, 197, 94);
private static final Color COLOR_WARNING = new Color(245, 158, 11);
private static final Color COLOR_TEXT = new Color(240, 240, 240);
private static final Color COLOR_TEXT_MUTED = new Color(150, 150, 150);
private static final Color COLOR_TEXT_SUBTLE = new Color(110, 110, 110);
private static final Color COLOR_BORDER = new Color(50, 50, 50);
private static final Font FONT_TITLE = new Font("Segoe UI", Font.BOLD, 26);
private static final Font FONT_SECTION = new Font("Segoe UI", Font.BOLD, 16);
private static final Font FONT_SMALL = new Font("Segoe UI", Font.PLAIN, 12);
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss");
private static EmulatorDashboard instance;
// Overview Tab
private final JLabel memLabel = createMetricLabel();
private final JLabel cpuLabel = createMetricLabel();
private final JLabel threadLabel = createMetricLabel();
private final JLabel usersLabel = createMetricLabel();
private final JLabel roomsLabel = createMetricLabel();
private final JLabel wiredLabel = createMetricLabel();
private final JLabel uptimeLabel = createStatusValueLabel();
private final JLabel lastUpdatedLabel = createStatusValueLabel();
private final JLabel footerStatusLabel = createStatusValueLabel();
private final MemoryGraphPanel memoryGraph = new MemoryGraphPanel();
// Tables
private final DefaultTableModel usersTableModel;
private final DefaultTableModel roomsTableModel;
private final DefaultTableModel wiredTableModel;
private final JTable usersTable;
private final JTable roomsTable;
private final JTable wiredTable;
private final JLabel usersCountLabel = createCountLabel();
private final JLabel roomsCountLabel = createCountLabel();
private final JLabel wiredCountLabel = createCountLabel();
// UI Components
private final JPanel cardsPanel;
private final CardLayout cardLayout;
private final Map<String, JPanel> navButtons = new HashMap<>();
private String selectedCardName = "Overview";
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "Dashboard-Updater");
t.setDaemon(true);
return t;
});
private EmulatorDashboard() {
setTitle("Arcturus Morningstar - System Dashboard");
setSize(1100, 700);
setDefaultCloseOperation(JFrame.HIDE_ON_CLOSE);
setLocationRelativeTo(null);
getContentPane().setBackground(COLOR_BG);
setLayout(new BorderLayout());
// Setup custom Look & Feel basics to remove weird Swing borders
UIManager.put("ScrollBar.background", COLOR_BG);
UIManager.put("ScrollBar.thumb", COLOR_SURFACE_HOVER);
// Sidebar
JPanel sidebar = new JPanel();
sidebar.setLayout(new BoxLayout(sidebar, BoxLayout.Y_AXIS));
sidebar.setBackground(COLOR_SURFACE);
sidebar.setPreferredSize(new Dimension(220, 0));
sidebar.setBorder(new MatteBorder(0, 0, 0, 1, COLOR_BORDER));
// Sidebar Header
JPanel brandPanel = new JPanel(new BorderLayout());
brandPanel.setBackground(COLOR_SURFACE);
brandPanel.setBorder(new EmptyBorder(20, 20, 30, 20));
JLabel brandTitle = new JLabel("Arcturus");
brandTitle.setFont(new Font("Segoe UI", Font.BOLD, 22));
brandTitle.setForeground(COLOR_TEXT);
JLabel brandSub = new JLabel("v" + Emulator.version);
brandSub.setFont(new Font("Segoe UI", Font.PLAIN, 12));
brandSub.setForeground(COLOR_PRIMARY);
brandPanel.add(brandTitle, BorderLayout.NORTH);
brandPanel.add(brandSub, BorderLayout.SOUTH);
sidebar.add(brandPanel);
// Main Cards
cardLayout = new CardLayout();
cardsPanel = new JPanel(cardLayout);
cardsPanel.setBackground(COLOR_BG);
// Setup Tabs
usersTableModel = createTableModel(new String[]{"ID", "Username", "Rank", "Credits", "Room ID"});
roomsTableModel = createTableModel(new String[]{"Room ID", "Name", "Players", "Items", "Tickables", "CPU (ms)", "Est. RAM (KB)", "Thread"});
wiredTableModel = createTableModel(new String[]{"Room ID", "Avg Tick", "Peak Tick", "Usage %", "Delayed", "Overloaded?", "Heavy?"});
usersTable = createDashboardTable(usersTableModel);
roomsTable = createDashboardTable(roomsTableModel);
wiredTable = createDashboardTable(wiredTableModel);
cardsPanel.add(createOverviewTab(), "Overview");
cardsPanel.add(createTableTab("Online Users", "Players currently connected to the emulator.", usersTable, usersCountLabel), "Online Users");
cardsPanel.add(createTableTab("Active Rooms", "Loaded rooms with lightweight performance indicators.", roomsTable, roomsCountLabel), "Active Rooms");
cardsPanel.add(createTableTab("Wired Diagnostics", "Rooms currently using wired timing, delay and execution budget.", wiredTable, wiredCountLabel), "Wired Diagnostics");
// Sidebar Navigation
sidebar.add(createNavButton("Overview", "Overview"));
sidebar.add(Box.createRigidArea(new Dimension(0, 10)));
sidebar.add(createNavButton("Online Users", "Online Users"));
sidebar.add(Box.createRigidArea(new Dimension(0, 10)));
sidebar.add(createNavButton("Active Rooms", "Active Rooms"));
sidebar.add(Box.createRigidArea(new Dimension(0, 10)));
sidebar.add(createNavButton("Wired Diagnostics", "Wired Diagnostics"));
sidebar.add(Box.createVerticalGlue());
add(sidebar, BorderLayout.WEST);
add(cardsPanel, BorderLayout.CENTER);
add(createStatusBar(), BorderLayout.SOUTH);
addComponentListener(new ComponentAdapter() {
@Override
public void componentShown(ComponentEvent e) {
setActiveCard("Overview");
}
});
// Start updates
scheduler.scheduleAtFixedRate(this::updateMetrics, 0, 1, TimeUnit.SECONDS);
}
private DefaultTableModel createTableModel(String[] cols) {
return new DefaultTableModel(cols, 0) {
@Override public boolean isCellEditable(int row, int column) { return false; }
};
}
private JPanel createNavButton(String text, String cardName) {
JPanel btn = new JPanel(new BorderLayout());
btn.setBackground(COLOR_SURFACE);
btn.setMaximumSize(new Dimension(220, 45));
btn.setBorder(new EmptyBorder(0, 18, 0, 0));
btn.setCursor(new Cursor(Cursor.HAND_CURSOR));
navButtons.put(cardName, btn);
JLabel lbl = new JLabel(text);
lbl.setFont(new Font("Segoe UI", Font.BOLD, 14));
lbl.setForeground(COLOR_TEXT_MUTED);
btn.add(lbl, BorderLayout.CENTER);
btn.addMouseListener(new MouseAdapter() {
@Override
public void mouseEntered(MouseEvent e) {
btn.setBackground(COLOR_SURFACE_HOVER);
lbl.setForeground(COLOR_TEXT);
}
@Override
public void mouseExited(MouseEvent e) {
updateNavButtonStyle(cardName, btn, lbl);
}
@Override
public void mouseClicked(MouseEvent e) {
setActiveCard(cardName);
}
});
updateNavButtonStyle(cardName, btn, lbl);
return btn;
}
private JPanel createOverviewTab() {
JPanel wrapper = new JPanel(new BorderLayout());
wrapper.setBackground(COLOR_BG);
wrapper.setBorder(new EmptyBorder(30, 30, 30, 30));
JPanel header = new JPanel(new BorderLayout(0, 14));
header.setOpaque(false);
JLabel title = new JLabel("Dashboard Overview");
title.setFont(FONT_TITLE);
title.setForeground(COLOR_TEXT);
JLabel subtitle = new JLabel("Operational view of emulator health, activity and wired performance.");
subtitle.setFont(new Font("Segoe UI", Font.PLAIN, 13));
subtitle.setForeground(COLOR_TEXT_MUTED);
JPanel titleBlock = new JPanel();
titleBlock.setOpaque(false);
titleBlock.setLayout(new BoxLayout(titleBlock, BoxLayout.Y_AXIS));
titleBlock.add(title);
titleBlock.add(Box.createRigidArea(new Dimension(0, 4)));
titleBlock.add(subtitle);
header.add(titleBlock, BorderLayout.NORTH);
header.add(createOverviewMetaPanel(), BorderLayout.SOUTH);
wrapper.add(header, BorderLayout.NORTH);
JPanel content = new JPanel(new GridLayout(1, 2, 20, 20));
content.setOpaque(false);
content.setBorder(new EmptyBorder(20, 0, 0, 0));
// Left Stats
JPanel statsPanel = new JPanel(new GridLayout(3, 2, 12, 12));
statsPanel.setOpaque(false);
statsPanel.add(createMetricCard("Memory Allocation", memLabel));
statsPanel.add(createMetricCard("CPU Load", cpuLabel));
statsPanel.add(createMetricCard("Active OS Threads", threadLabel));
statsPanel.add(createMetricCard("Connected Players", usersLabel));
statsPanel.add(createMetricCard("Loaded Rooms", roomsLabel));
statsPanel.add(createMetricCard("Wired Tickables", wiredLabel));
content.add(statsPanel);
// Right Graph
JPanel graphContainer = new JPanel(new BorderLayout());
graphContainer.setBackground(COLOR_SURFACE);
graphContainer.setBorder(BorderFactory.createCompoundBorder(
new MatteBorder(1, 1, 1, 1, COLOR_BORDER),
new EmptyBorder(15, 15, 15, 15)
));
JLabel gTitle = new JLabel("Realtime Memory Usage");
gTitle.setFont(FONT_SECTION);
gTitle.setForeground(COLOR_TEXT_MUTED);
gTitle.setBorder(new EmptyBorder(0, 0, 15, 0));
graphContainer.add(gTitle, BorderLayout.NORTH);
graphContainer.add(memoryGraph, BorderLayout.CENTER);
content.add(graphContainer);
wrapper.add(content, BorderLayout.CENTER);
return wrapper;
}
private JPanel createMetricCard(String title, JLabel valueLabel) {
JPanel card = new JPanel(new BorderLayout());
card.setBackground(COLOR_SURFACE);
card.setBorder(BorderFactory.createCompoundBorder(
new MatteBorder(1, 1, 1, 1, COLOR_BORDER),
new EmptyBorder(15, 20, 15, 20)
));
JLabel tLabel = new JLabel(title);
tLabel.setFont(new Font("Segoe UI", Font.BOLD, 13));
tLabel.setForeground(COLOR_TEXT_MUTED);
card.add(tLabel, BorderLayout.NORTH);
card.add(valueLabel, BorderLayout.SOUTH);
return card;
}
private JLabel createMetricLabel() {
JLabel label = new JLabel("-");
label.setFont(new Font("Segoe UI", Font.BOLD, 28));
label.setForeground(COLOR_TEXT);
return label;
}
private JPanel createOverviewMetaPanel() {
JPanel panel = new JPanel(new GridLayout(1, 3, 12, 12));
panel.setOpaque(false);
panel.add(createStatusCard("Uptime", uptimeLabel, COLOR_PRIMARY));
panel.add(createStatusCard("Last Refresh", lastUpdatedLabel, COLOR_SUCCESS));
panel.add(createStatusCard("GUI Status", footerStatusLabel, COLOR_WARNING));
return panel;
}
private JPanel createStatusCard(String title, JLabel valueLabel, Color accent) {
JPanel panel = new JPanel(new BorderLayout());
panel.setBackground(COLOR_SURFACE);
panel.setBorder(BorderFactory.createCompoundBorder(
new MatteBorder(1, 1, 1, 1, COLOR_BORDER),
new EmptyBorder(12, 14, 12, 14)
));
JPanel accentBar = new JPanel();
accentBar.setBackground(accent);
accentBar.setPreferredSize(new Dimension(6, 0));
JPanel content = new JPanel();
content.setOpaque(false);
content.setLayout(new BoxLayout(content, BoxLayout.Y_AXIS));
JLabel label = new JLabel(title);
label.setFont(FONT_SMALL);
label.setForeground(COLOR_TEXT_MUTED);
content.add(label);
content.add(Box.createRigidArea(new Dimension(0, 6)));
content.add(valueLabel);
panel.add(accentBar, BorderLayout.WEST);
panel.add(content, BorderLayout.CENTER);
return panel;
}
private JLabel createStatusValueLabel() {
JLabel label = new JLabel("-");
label.setFont(new Font("Segoe UI", Font.BOLD, 16));
label.setForeground(COLOR_TEXT);
return label;
}
private JLabel createCountLabel() {
JLabel label = new JLabel("0 rows");
label.setFont(FONT_SMALL);
label.setForeground(COLOR_TEXT_MUTED);
return label;
}
private JTable createDashboardTable(DefaultTableModel model) {
JTable table = new JTable(model);
table.setBackground(COLOR_SURFACE);
table.setForeground(COLOR_TEXT);
table.setGridColor(COLOR_BORDER);
table.setRowHeight(34);
table.setFont(new Font("Segoe UI", Font.PLAIN, 13));
table.setFillsViewportHeight(true);
table.setSelectionBackground(COLOR_PRIMARY);
table.setSelectionForeground(Color.WHITE);
table.setShowVerticalLines(false);
table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
table.setAutoCreateRowSorter(true);
JTableHeader header = table.getTableHeader();
header.setBackground(new Color(22, 22, 22));
header.setForeground(COLOR_TEXT_MUTED);
header.setFont(new Font("Segoe UI", Font.BOLD, 13));
header.setPreferredSize(new Dimension(0, 38));
header.setBorder(BorderFactory.createMatteBorder(1, 0, 1, 0, COLOR_BORDER));
((DefaultTableCellRenderer) header.getDefaultRenderer()).setHorizontalAlignment(JLabel.LEFT);
((DefaultTableCellRenderer) header.getDefaultRenderer()).setBorder(new EmptyBorder(0, 10, 0, 0));
table.setDefaultRenderer(Object.class, new DefaultTableCellRenderer() {
@Override
public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) {
JLabel label = (JLabel) super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
label.setBorder(new EmptyBorder(0, 10, 0, 10));
label.setForeground(isSelected ? Color.WHITE : COLOR_TEXT);
label.setBackground(isSelected ? COLOR_PRIMARY : ((row % 2 == 0) ? COLOR_SURFACE : new Color(35, 35, 35)));
return label;
}
});
return table;
}
private JPanel createTableTab(String title, String subtitle, JTable table, JLabel countLabel) {
JPanel wrapper = new JPanel(new BorderLayout());
wrapper.setBackground(COLOR_BG);
wrapper.setBorder(new EmptyBorder(30, 30, 30, 30));
JPanel titlePanel = new JPanel(new BorderLayout());
titlePanel.setOpaque(false);
JLabel titleLbl = new JLabel(title);
titleLbl.setFont(FONT_TITLE);
titleLbl.setForeground(COLOR_TEXT);
JLabel subtitleLbl = new JLabel(subtitle);
subtitleLbl.setFont(new Font("Segoe UI", Font.PLAIN, 13));
subtitleLbl.setForeground(COLOR_TEXT_MUTED);
subtitleLbl.setBorder(new EmptyBorder(6, 0, 0, 0));
JPanel textPanel = new JPanel();
textPanel.setOpaque(false);
textPanel.setLayout(new BoxLayout(textPanel, BoxLayout.Y_AXIS));
textPanel.add(titleLbl);
textPanel.add(subtitleLbl);
titlePanel.add(textPanel, BorderLayout.WEST);
titlePanel.add(countLabel, BorderLayout.EAST);
wrapper.add(titlePanel, BorderLayout.NORTH);
JScrollPane scrollPane = new JScrollPane(table);
scrollPane.getViewport().setBackground(COLOR_SURFACE);
scrollPane.setBorder(new MatteBorder(1, 1, 1, 1, COLOR_BORDER));
scrollPane.setBorder(new CompoundBorder(
new EmptyBorder(20, 0, 0, 0),
new MatteBorder(1, 1, 1, 1, COLOR_BORDER)
));
wrapper.add(scrollPane, BorderLayout.CENTER);
return wrapper;
}
private JPanel createStatusBar() {
JPanel statusBar = new JPanel(new BorderLayout());
statusBar.setBackground(COLOR_SURFACE);
statusBar.setBorder(new CompoundBorder(
new MatteBorder(1, 0, 0, 0, COLOR_BORDER),
new EmptyBorder(8, 14, 8, 14)
));
JLabel left = new JLabel("Dashboard running locally");
left.setFont(FONT_SMALL);
left.setForeground(COLOR_TEXT_SUBTLE);
JLabel right = new JLabel("Tip: table columns are sortable");
right.setFont(FONT_SMALL);
right.setForeground(COLOR_TEXT_SUBTLE);
statusBar.add(left, BorderLayout.WEST);
statusBar.add(right, BorderLayout.EAST);
return statusBar;
}
private void setActiveCard(String cardName) {
selectedCardName = cardName;
cardLayout.show(cardsPanel, cardName);
navButtons.forEach((name, button) -> {
JLabel label = (JLabel) button.getComponent(0);
updateNavButtonStyle(name, button, label);
});
}
private void updateNavButtonStyle(String cardName, JPanel button, JLabel label) {
boolean active = cardName.equals(selectedCardName);
button.setBackground(active ? COLOR_PRIMARY_SOFT : COLOR_SURFACE);
label.setForeground(active ? COLOR_TEXT : COLOR_TEXT_MUTED);
}
private void updateMetrics() {
try {
EmulatorStatsService.Snapshot snapshot = EmulatorStatsService.collectSnapshot();
EmulatorStatsService.Overview overview = snapshot.overview;
Object[][] usersData = new Object[snapshot.users.size()][5];
for (int i = 0; i < snapshot.users.size(); i++) {
EmulatorStatsService.OnlineUserRow user = snapshot.users.get(i);
usersData[i] = new Object[]{user.id, user.username, user.rank, user.credits, user.roomId};
}
Object[][] roomsData = new Object[snapshot.rooms.size()][8];
for (int i = 0; i < snapshot.rooms.size(); i++) {
EmulatorStatsService.ActiveRoomRow room = snapshot.rooms.get(i);
roomsData[i] = new Object[]{
room.roomId,
room.name,
room.players,
room.items,
room.tickables,
String.format("%.2f", room.cpuMs),
room.estimatedRamKb,
room.thread
};
}
Object[][] wiredData = new Object[snapshot.wired.size()][7];
for (int i = 0; i < snapshot.wired.size(); i++) {
EmulatorStatsService.WiredRoomRow wiredRoom = snapshot.wired.get(i);
wiredData[i] = new Object[]{
wiredRoom.roomId,
wiredRoom.averageTickMs + " ms",
wiredRoom.peakTickMs + " ms",
wiredRoom.usagePercent + "%",
wiredRoom.delayedEventsPending,
wiredRoom.overloaded ? "YES" : "NO",
wiredRoom.heavy ? "YES" : "NO"
};
}
SwingUtilities.invokeLater(() -> {
memLabel.setText(String.format("%d MB / %d MB", overview.memoryUsedMb, overview.memoryMaxMb));
cpuLabel.setText(String.format("%.1f %%", overview.cpuLoadPercent));
threadLabel.setText(String.valueOf(overview.activeOsThreads));
usersLabel.setText(String.valueOf(overview.connectedPlayers));
roomsLabel.setText(String.valueOf(overview.loadedRooms));
wiredLabel.setText(String.valueOf(overview.wiredTickables));
uptimeLabel.setText(EmulatorStatsService.formatDuration(overview.uptimeSeconds));
lastUpdatedLabel.setText(LocalDateTime.now().format(TIME_FORMAT));
footerStatusLabel.setText(overview.guiStatus);
memoryGraph.addValue((long) overview.memoryUsedMb * 1024L * 1024L, (long) overview.memoryMaxMb * 1024L * 1024L);
usersTableModel.setDataVector(usersData, new String[]{"ID", "Username", "Rank", "Credits", "Room ID"});
roomsTableModel.setDataVector(roomsData, new String[]{"Room ID", "Name", "Players", "Items", "Tickables", "CPU (ms)", "Est. RAM (KB)", "Thread"});
wiredTableModel.setDataVector(wiredData, new String[]{"Room ID", "Avg Tick", "Peak Tick", "Usage %", "Delayed", "Overloaded?", "Heavy?"});
usersCountLabel.setText(snapshot.users.size() + " rows");
roomsCountLabel.setText(snapshot.rooms.size() + " rows");
wiredCountLabel.setText(snapshot.wired.size() + " rows");
});
} catch (Exception e) {
LOGGER.error("Error updating dashboard metrics", e);
}
}
public static void launch() {
if (instance == null) {
instance = new EmulatorDashboard();
}
SwingUtilities.invokeLater(() -> {
instance.setVisible(true);
});
}
private static class MemoryGraphPanel extends JPanel {
private final LinkedList<Double> history = new LinkedList<>();
private static final int MAX_POINTS = 100;
public MemoryGraphPanel() {
setOpaque(false);
}
public void addValue(long used, long max) {
double percent = (double) used / (double) max;
history.addLast(percent);
if (history.size() > MAX_POINTS) {
history.removeFirst();
}
repaint();
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g;
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
int width = getWidth();
int height = getHeight();
// Background grid and labels
g2.setFont(new Font("Segoe UI", Font.PLAIN, 10));
long maxMemRaw = Runtime.getRuntime().maxMemory();
for(int i = 0; i <= 4; i++) {
int y = i == 0 ? 15 : height * i / 4;
if (i == 4) y = height - 5;
g2.setColor(COLOR_BORDER);
g2.drawLine(0, y, width, y);
// Draw Y-axis numbers
g2.setColor(COLOR_TEXT_MUTED);
long labelVal = (long) (maxMemRaw * (1.0 - (double)i / 4.0)) / 1024 / 1024;
g2.drawString(labelVal + " MB", 5, y - 5);
}
if (history.size() < 2) return;
double dx = (double) width / (MAX_POINTS - 1);
Path2D path = new Path2D.Double();
path.moveTo(0, height);
int i = MAX_POINTS - history.size();
for (Double val : history) {
double x = i * dx;
double y = height - (val * height);
if (i == MAX_POINTS - history.size()) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
i++;
}
// Draw line
g2.setStroke(new BasicStroke(3.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
g2.setColor(COLOR_PRIMARY);
g2.draw(path);
// Fill area
path.lineTo(width, height);
path.lineTo((MAX_POINTS - history.size()) * dx, height);
path.closePath();
GradientPaint fillPaint = new GradientPaint(
0, 0, new Color(COLOR_PRIMARY.getRed(), COLOR_PRIMARY.getGreen(), COLOR_PRIMARY.getBlue(), 120),
0, height, new Color(COLOR_PRIMARY.getRed(), COLOR_PRIMARY.getGreen(), COLOR_PRIMARY.getBlue(), 10)
);
g2.setPaint(fillPaint);
g2.fill(path);
Double lastValue = history.peekLast();
if (lastValue != null) {
String usageLabel = String.format("Usage %.1f%%", lastValue * 100.0);
g2.setFont(new Font("Segoe UI", Font.BOLD, 12));
FontMetrics metrics = g2.getFontMetrics();
int labelWidth = metrics.stringWidth(usageLabel) + 16;
int labelHeight = 24;
int labelX = Math.max(8, width - labelWidth - 8);
int labelY = 8;
g2.setColor(new Color(0, 0, 0, 130));
g2.fillRoundRect(labelX, labelY, labelWidth, labelHeight, 12, 12);
g2.setColor(COLOR_TEXT);
g2.drawString(usageLabel, labelX + 8, labelY + 16);
}
}
}
private static String formatDuration(long millis) {
long totalSeconds = Math.max(0L, millis / 1000L);
long hours = totalSeconds / 3600L;
long minutes = (totalSeconds % 3600L) / 60L;
long seconds = totalSeconds % 60L;
return String.format("%02dh %02dm %02ds", hours, minutes, seconds);
}
}
@@ -6,11 +6,15 @@ import com.eu.habbo.habbohotel.achievements.AchievementManager;
import com.eu.habbo.habbohotel.bots.BotManager;
import com.eu.habbo.habbohotel.campaign.calendar.CalendarManager;
import com.eu.habbo.habbohotel.catalog.CatalogManager;
import com.eu.habbo.habbohotel.wheel.WheelManager;
import com.eu.habbo.habbohotel.soundboard.SoundboardManager;
import com.eu.habbo.habbohotel.mentions.MentionManager;
import com.eu.habbo.habbohotel.commands.CommandHandler;
import com.eu.habbo.habbohotel.crafting.CraftingManager;
import com.eu.habbo.habbohotel.guides.GuideManager;
import com.eu.habbo.habbohotel.guilds.GuildManager;
import com.eu.habbo.habbohotel.hotelview.HotelViewManager;
import com.eu.habbo.habbohotel.items.FurnitureTextProvider;
import com.eu.habbo.habbohotel.items.ItemManager;
import com.eu.habbo.habbohotel.modtool.ModToolManager;
import com.eu.habbo.habbohotel.modtool.ModToolSanctions;
@@ -44,6 +48,7 @@ public class GameEnvironment {
private NavigatorManager navigatorManager;
private GuildManager guildManager;
private ItemManager itemManager;
private FurnitureTextProvider furnitureTextProvider;
private CatalogManager catalogManager;
private HotelViewManager hotelViewManager;
private RoomManager roomManager;
@@ -64,6 +69,9 @@ public class GameEnvironment {
private GoogleTranslateManager googleTranslateManager;
private CustomBadgeManager customBadgeManager;
private InfostandBackgroundManager infostandBackgroundManager;
private WheelManager wheelManager;
private SoundboardManager soundboardManager;
private MentionManager mentionManager;
public void load() throws Exception {
LOGGER.info("GameEnvironment -> Loading...");
@@ -73,6 +81,8 @@ public class GameEnvironment {
this.hotelViewManager = new HotelViewManager();
this.itemManager = new ItemManager();
this.itemManager.load();
this.furnitureTextProvider = new FurnitureTextProvider();
this.furnitureTextProvider.init();
this.botManager = new BotManager();
this.petManager = new PetManager();
this.guildManager = new GuildManager();
@@ -93,6 +103,9 @@ public class GameEnvironment {
this.googleTranslateManager = new GoogleTranslateManager();
this.customBadgeManager = new CustomBadgeManager();
this.infostandBackgroundManager = new InfostandBackgroundManager();
this.wheelManager = new WheelManager();
this.soundboardManager = new SoundboardManager();
this.mentionManager = new MentionManager();
this.roomManager.loadPublicRooms();
this.navigatorManager.loadNavigator();
@@ -152,10 +165,22 @@ public class GameEnvironment {
return this.itemManager;
}
public FurnitureTextProvider getFurnitureTextProvider() {
return this.furnitureTextProvider;
}
public CatalogManager getCatalogManager() {
return this.catalogManager;
}
public WheelManager getWheelManager() {
return this.wheelManager;
}
public SoundboardManager getSoundboardManager() {
return this.soundboardManager;
}
public HotelViewManager getHotelViewManager() {
return this.hotelViewManager;
}
@@ -188,6 +213,10 @@ public class GameEnvironment {
return this.petManager;
}
public MentionManager getMentionManager() {
return this.mentionManager;
}
public AchievementManager getAchievementManager() {
return this.achievementManager;
}
@@ -100,9 +100,9 @@ public class AchievementManager {
if (oldLevel != null && (oldLevel.level == achievement.levels.size() && currentProgress >= oldLevel.progress)) //Maximum achievement gotten.
return;
habbo.getHabboStats().setProgress(achievement, currentProgress + amount);
int newProgress = habbo.getHabboStats().incrementProgress(achievement, amount);
AchievementLevel newLevel = achievement.getLevelForProgress(currentProgress + amount);
AchievementLevel newLevel = achievement.getLevelForProgress(newProgress);
if (AchievementManager.TALENTTRACK_ENABLED) {
for (TalentTrackType type : TalentTrackType.values()) {
@@ -138,18 +138,20 @@ public class Bot implements Runnable {
@Override
public void run() {
if (this.needsUpdate) {
Room localRoom = this.room;
RoomUnit localRoomUnit = this.roomUnit;
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("UPDATE bots SET name = ?, motto = ?, figure = ?, gender = ?, user_id = ?, room_id = ?, x = ?, y = ?, z = ?, rot = ?, dance = ?, freeroam = ?, chat_lines = ?, chat_auto = ?, chat_random = ?, chat_delay = ?, effect = ?, bubble_id = ? WHERE id = ?")) {
statement.setString(1, this.name);
statement.setString(2, this.motto);
statement.setString(3, this.figure);
statement.setString(4, this.gender.toString());
statement.setInt(5, this.ownerId);
statement.setInt(6, this.room == null ? 0 : this.room.getId());
statement.setInt(7, this.roomUnit == null ? 0 : this.roomUnit.getX());
statement.setInt(8, this.roomUnit == null ? 0 : this.roomUnit.getY());
statement.setDouble(9, this.roomUnit == null ? 0 : this.roomUnit.getZ());
statement.setInt(10, this.roomUnit == null ? 0 : this.roomUnit.getBodyRotation().getValue());
statement.setInt(11, this.roomUnit == null ? 0 : this.roomUnit.getDanceType().getType());
statement.setInt(6, localRoom == null ? 0 : localRoom.getId());
statement.setInt(7, localRoomUnit == null ? 0 : localRoomUnit.getX());
statement.setInt(8, localRoomUnit == null ? 0 : localRoomUnit.getY());
statement.setDouble(9, localRoomUnit == null ? 0 : localRoomUnit.getZ());
statement.setInt(10, localRoomUnit == null ? 0 : localRoomUnit.getBodyRotation().getValue());
statement.setInt(11, localRoomUnit == null ? 0 : localRoomUnit.getDanceType().getType());
statement.setString(12, this.canWalk ? "1" : "0");
StringBuilder text = new StringBuilder();
for (String s : this.chatLines) {
@@ -187,11 +189,7 @@ public class Bot implements Runnable {
int timeOut = Emulator.getRandom().nextInt(20) * 2;
this.roomUnit.setWalkTimeOut((timeOut < 10 ? 5 : timeOut) + Emulator.getIntUnixTimestamp());
}
}/* else {
for (RoomTile t : this.room.getLayout().getTilesAround(this.room.getLayout().getTile(this.getRoomUnit().getX(), this.getRoomUnit().getY()))) {
WiredManager.handle(WiredTriggerType.BOT_REACHED_STF, this.roomUnit, this.room, this.room.getItemsAt(t).toArray());
}
}*/
}
}
if (!this.chatLines.isEmpty() && this.chatTimeOut <= Emulator.getIntUnixTimestamp() && this.chatAuto) {
@@ -216,7 +214,7 @@ public class Bot implements Runnable {
} else {
this.lastChatIndex++;
if (this.lastChatIndex >= this.chatLines.size()) {
this.lastChatIndex = 0; // start from scratch :-3
this.lastChatIndex = 0;
}
}
@@ -282,7 +280,7 @@ public class Bot implements Runnable {
}
public void onPickUp(Habbo habbo, Room room) {
this.stopFollowingHabbo();
}
public void onUserSay(final RoomChatMessage message) {
@@ -308,9 +306,6 @@ public class Bot implements Runnable {
public void setName(String name) {
this.name = name;
this.needsUpdate = true;
//if(this.room != null)
//this.room.sendComposer(new ChangeNameUpdatedComposer(this.getRoomUnit(), this.getName()).compose());
}
public String getMotto() {
@@ -537,5 +532,28 @@ public class Bot implements Runnable {
}
}
private static final short[] DEFAULT_OWNER_ACTION_IDS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11};
public static final int ACTION_ROTATE = 11;
private static final long MIN_OWNER_ACTION_INTERVAL_MS = 200L;
private volatile long lastOwnerActionAt;
public short[] getOwnerActionIds() {
return DEFAULT_OWNER_ACTION_IDS;
}
public synchronized boolean tryAcquireOwnerActionSlot() {
long now = System.currentTimeMillis();
if (now - this.lastOwnerActionAt < MIN_OWNER_ACTION_INTERVAL_MS) {
return false;
}
this.lastOwnerActionAt = now;
return true;
}
public void onPostOwnerAction(int actionId) {
// no-op default
}
}
@@ -41,6 +41,7 @@ public class BotManager {
addBotDefinition("generic", Bot.class);
addBotDefinition("bartender", ButlerBot.class);
addBotDefinition("visitor_log", VisitorBot.class);
addBotDefinition(FrankBot.BOT_TYPE, FrankBot.class);
this.reload();
@@ -187,7 +188,11 @@ public class BotManager {
if (pickedUpEvent.isCancelled())
return;
if (habbo == null || (bot.getOwnerId() == habbo.getHabboInfo().getId() || habbo.hasPermission(Permission.ACC_ANYROOMOWNER))) {
Room currentRoom = habbo != null ? habbo.getHabboInfo().getCurrentRoom() : null;
if (habbo == null
|| bot.getOwnerId() == habbo.getHabboInfo().getId()
|| habbo.hasPermission(Permission.ACC_ANYROOMOWNER)
|| (currentRoom != null && (currentRoom.getOwnerId() == habbo.getHabboInfo().getId() || habbo.hasPermission(Permission.ACC_PLACEFURNI)))) {
if (habbo != null && !habbo.hasPermission(Permission.ACC_UNLIMITED_BOTS) && habbo.getInventory().getBotsComponent().getBots().size() >= BotManager.MAXIMUM_BOT_INVENTORY_SIZE) {
habbo.alert(Emulator.getTexts().getValue("error.bots.max.inventory").replace("%amount%", BotManager.MAXIMUM_BOT_INVENTORY_SIZE + ""));
return;
@@ -20,10 +20,13 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
public class ButlerBot extends Bot {
private static final Logger LOGGER = LoggerFactory.getLogger(ButlerBot.class);
public static THashMap<THashSet<String>, Integer> serveItems = new THashMap<>();
private static final ConcurrentHashMap<Pattern, Integer> serveItemsCompiled = new ConcurrentHashMap<>();
public ButlerBot(ResultSet set) throws SQLException {
super(set);
@@ -38,6 +41,7 @@ public class ButlerBot extends Bot {
serveItems = new THashMap<>();
serveItems.clear();
serveItemsCompiled.clear();
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); Statement statement = connection.createStatement(); ResultSet set = statement.executeQuery("SELECT * FROM bot_serves")) {
while (set.next()) {
@@ -45,6 +49,17 @@ public class ButlerBot extends Bot {
THashSet<String> ks = new THashSet<>();
Collections.addAll(ks, keys);
serveItems.put(ks, set.getInt("item"));
for (String key : keys) {
if (key != null && !key.trim().isEmpty()) {
try {
Pattern pattern = Pattern.compile("\\b" + Pattern.quote(key.toLowerCase()) + "\\b");
serveItemsCompiled.put(pattern, set.getInt("item"));
} catch (Exception e) {
LOGGER.error("Failed to compile butler bot keyword pattern: {}", key, e);
}
}
}
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
@@ -53,6 +68,7 @@ public class ButlerBot extends Bot {
public static void dispose() {
serveItems.clear();
serveItemsCompiled.clear();
}
@Override
@@ -66,74 +82,73 @@ public class ButlerBot extends Bot {
if (distanceBetweenBotAndHabbo <= Emulator.getConfig().getInt("hotel.bot.butler.commanddistance")) {
if (message.getUnfilteredMessage() != null) {
for (Map.Entry<THashSet<String>, Integer> set : serveItems.entrySet()) {
for (String keyword : set.getKey()) {
String unfilteredLower = message.getUnfilteredMessage().toLowerCase();
for (Map.Entry<Pattern, Integer> entry : serveItemsCompiled.entrySet()) {
Pattern pattern = entry.getKey();
if (pattern.matcher(unfilteredLower).matches()) {
int itemId = entry.getValue();
String keyword = pattern.pattern().replace("\\b", "").replace("\\Q", "").replace("\\E", "");
// Check if the string contains a certain keyword using a regex.
// If keyword = tea, teapot wouldn't trigger it.
if (message.getUnfilteredMessage().toLowerCase().matches("\\b" + keyword + "\\b")) {
// Enable plugins to cancel this event
BotServerItemEvent serveEvent = new BotServerItemEvent(this, message.getHabbo(), set.getValue());
if (Emulator.getPluginManager().fireEvent(serveEvent).isCancelled()) {
return;
}
// Start give handitem process
if (this.getRoomUnit().canWalk()) {
final String key = keyword;
final Bot bot = this;
// Step 1: Look at Habbo
bot.lookAt(serveEvent.habbo);
// Step 2: Prepare tasks for when the Bot (carrying the handitem) reaches the Habbo
final List<Runnable> tasks = new ArrayList<>();
tasks.add(new RoomUnitGiveHanditem(serveEvent.habbo.getRoomUnit(), serveEvent.habbo.getHabboInfo().getCurrentRoom(), serveEvent.itemId));
tasks.add(new RoomUnitGiveHanditem(this.getRoomUnit(), serveEvent.habbo.getHabboInfo().getCurrentRoom(), 0));
tasks.add(() -> {
if(this.getRoom() != null) {
String botMessage = Emulator.getTexts()
.getValue("bots.butler.given")
.replace("%key%", key)
.replace("%username%", serveEvent.habbo.getHabboInfo().getUsername());
if (!WiredManager.triggerUserSays(this.getRoom(), this.getRoomUnit(), botMessage)) {
bot.talk(botMessage);
}
}
});
List<Runnable> failedReached = new ArrayList<>();
failedReached.add(() -> {
if (distanceBetweenBotAndHabbo <= Emulator.getConfig().getInt("hotel.bot.butler.servedistance", 8)) {
for (Runnable task : tasks) {
task.run();
}
}
});
// Give bot the handitem that it's going to give the Habbo
Emulator.getThreading().run(new RoomUnitGiveHanditem(this.getRoomUnit(), serveEvent.habbo.getHabboInfo().getCurrentRoom(), serveEvent.itemId));
if (distanceBetweenBotAndHabbo > Emulator.getConfig().getInt("hotel.bot.butler.reachdistance", 3)) {
Emulator.getThreading().run(new RoomUnitWalkToRoomUnit(this.getRoomUnit(), serveEvent.habbo.getRoomUnit(), serveEvent.habbo.getHabboInfo().getCurrentRoom(), tasks, failedReached, Emulator.getConfig().getInt("hotel.bot.butler.reachdistance", 3)));
} else {
Emulator.getThreading().run(failedReached.get(0), 1000);
}
} else {
if(this.getRoom() != null) {
this.getRoom().giveHandItem(serveEvent.habbo, serveEvent.itemId);
String msg = Emulator.getTexts().getValue("bots.butler.given").replace("%key%", keyword).replace("%username%", serveEvent.habbo.getHabboInfo().getUsername());
if (!WiredManager.triggerUserSays(this.getRoom(), this.getRoomUnit(), msg)) {
this.talk(msg);
}
}
}
// Enable plugins to cancel this event
BotServerItemEvent serveEvent = new BotServerItemEvent(this, message.getHabbo(), itemId);
if (Emulator.getPluginManager().fireEvent(serveEvent).isCancelled()) {
return;
}
// Start give handitem process
if (this.getRoomUnit().canWalk()) {
final String key = keyword;
final Bot bot = this;
// Step 1: Look at Habbo
bot.lookAt(serveEvent.habbo);
// Step 2: Prepare tasks for when the Bot (carrying the handitem) reaches the Habbo
final List<Runnable> tasks = new ArrayList<>();
tasks.add(new RoomUnitGiveHanditem(serveEvent.habbo.getRoomUnit(), serveEvent.habbo.getHabboInfo().getCurrentRoom(), serveEvent.itemId));
tasks.add(new RoomUnitGiveHanditem(this.getRoomUnit(), serveEvent.habbo.getHabboInfo().getCurrentRoom(), 0));
tasks.add(() -> {
if(this.getRoom() != null) {
String botMessage = Emulator.getTexts()
.getValue("bots.butler.given")
.replace("%key%", key)
.replace("%username%", serveEvent.habbo.getHabboInfo().getUsername());
if (!WiredManager.triggerUserSays(this.getRoom(), this.getRoomUnit(), botMessage)) {
bot.talk(botMessage);
}
}
});
List<Runnable> failedReached = new ArrayList<>();
failedReached.add(() -> {
if (distanceBetweenBotAndHabbo <= Emulator.getConfig().getInt("hotel.bot.butler.servedistance", 8)) {
for (Runnable task : tasks) {
task.run();
}
}
});
// Give bot the handitem that it's going to give the Habbo
Emulator.getThreading().run(new RoomUnitGiveHanditem(this.getRoomUnit(), serveEvent.habbo.getHabboInfo().getCurrentRoom(), serveEvent.itemId));
if (distanceBetweenBotAndHabbo > Emulator.getConfig().getInt("hotel.bot.butler.reachdistance", 3)) {
Emulator.getThreading().run(new RoomUnitWalkToRoomUnit(this.getRoomUnit(), serveEvent.habbo.getRoomUnit(), serveEvent.habbo.getHabboInfo().getCurrentRoom(), tasks, failedReached, Emulator.getConfig().getInt("hotel.bot.butler.reachdistance", 3)));
} else {
Emulator.getThreading().run(failedReached.get(0), 1000);
}
} else {
if(this.getRoom() != null) {
this.getRoom().giveHandItem(serveEvent.habbo, serveEvent.itemId);
String msg = Emulator.getTexts().getValue("bots.butler.given").replace("%key%", keyword).replace("%username%", serveEvent.habbo.getHabboInfo().getUsername());
if (!WiredManager.triggerUserSays(this.getRoom(), this.getRoomUnit(), msg)) {
this.talk(msg);
}
}
}
return;
}
}
}
@@ -0,0 +1,455 @@
package com.eu.habbo.habbohotel.bots;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.rooms.*;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.outgoing.rooms.users.RoomUserStatusComposer;
import com.eu.habbo.messages.outgoing.rooms.users.RoomUserWhisperComposer;
import com.eu.habbo.threading.runnables.RoomUnitWalkToLocation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Pattern;
public class FrankBot extends ButlerBot {
private static final Logger LOGGER = LoggerFactory.getLogger(FrankBot.class);
public static final String BOT_TYPE = "frank";
public static final String PERMISSION_USE = "acc_bot_frank";
private static final String KEY_DOOR_LINES = "__door_lines";
private static final String KEY_BUSY_WHISPER = "__busy_whisper";
private static final String KEY_DOOR_TRIGGERS = "__door_triggers";
private static final List<String> DEFAULT_DOOR_LINES = List.of(
"Right this way - mind the step!",
"And out you go. Come back soon!",
"Allow me to escort you to the exit.",
"There's the door. Farewell, true believer!"
);
private static final String DEFAULT_BUSY_WHISPER =
"Sorry, I am currently busy. Please wait until I am available.";
private static final Pattern DEFAULT_DOOR_PATTERN = Pattern.compile(
"\\b(show me the door|kick me|i want to leave|let me out)\\b");
private static final ConcurrentHashMap<Pattern, List<String>> chatResponses = new ConcurrentHashMap<>();
private static volatile List<String> doorLines = DEFAULT_DOOR_LINES;
private static volatile String busyWhisper = DEFAULT_BUSY_WHISPER;
private static volatile Pattern doorTriggerPattern = DEFAULT_DOOR_PATTERN;
private static final Random RANDOM = new Random();
private static final int MAX_CHAT_KEYWORDS = 256;
private static final int MAX_DOOR_TRIGGERS = 32;
private static final int MAX_MESSAGE_LEN = 256;
private static final long BUSY_WHISPER_COOLDOWN_MS = 5000L;
private volatile RoomTile homeTile;
private volatile RoomUserRotation homeRotation;
private final AtomicBoolean busy = new AtomicBoolean(false);
private final AtomicBoolean returnScheduled = new AtomicBoolean(false);
private final ConcurrentHashMap<Integer, Long> lastBusyWhisperAt = new ConcurrentHashMap<>();
public FrankBot(ResultSet set) throws SQLException {
super(set);
}
public FrankBot(Bot bot) {
super(bot);
}
@Override
public void onPlace(Habbo habbo, Room room) {
super.onPlace(habbo, room);
if (this.getRoomUnit() != null) {
this.homeTile = this.getRoomUnit().getCurrentLocation();
this.homeRotation = this.getRoomUnit().getBodyRotation();
}
}
private static final short[] FRANK_OWNER_ACTIONS = { (short) Bot.ACTION_ROTATE };
@Override
public short[] getOwnerActionIds() {
return FRANK_OWNER_ACTIONS;
}
@Override
public void onPostOwnerAction(int actionId) {
if (actionId == ACTION_ROTATE && this.getRoomUnit() != null) {
this.homeRotation = this.getRoomUnit().getBodyRotation();
}
}
public static void initialise() {
chatResponses.clear();
doorLines = DEFAULT_DOOR_LINES;
busyWhisper = DEFAULT_BUSY_WHISPER;
doorTriggerPattern = DEFAULT_DOOR_PATTERN;
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
Statement statement = connection.createStatement();
ResultSet set = statement.executeQuery("SELECT `keys`, `responses` FROM bot_chat_responses WHERE bot_type = '" + BOT_TYPE + "'")) {
while (set.next()) {
String keysRaw = set.getString("keys");
String responsesRaw = set.getString("responses");
if (keysRaw == null || responsesRaw == null) continue;
List<String> responses = new ArrayList<>();
for (String line : responsesRaw.split("\n")) {
String trimmed = line.trim();
if (!trimmed.isEmpty()) responses.add(trimmed);
}
if (responses.isEmpty()) continue;
String firstKey = keysRaw.split(";", 2)[0].trim();
if (firstKey.startsWith("__")) {
switch (firstKey) {
case KEY_DOOR_LINES:
doorLines = new CopyOnWriteArrayList<>(responses);
break;
case KEY_BUSY_WHISPER:
busyWhisper = responses.get(0);
break;
case KEY_DOOR_TRIGGERS:
doorTriggerPattern = buildDoorTriggerPattern(responses);
break;
default:
LOGGER.warn("FrankBot: unknown system key '{}', ignored", firstKey);
}
continue;
}
List<String> shared = new CopyOnWriteArrayList<>(responses);
for (String key : keysRaw.split(";")) {
if (chatResponses.size() >= MAX_CHAT_KEYWORDS) {
LOGGER.warn("FrankBot: chat keyword cap ({}) reached, remaining rows ignored",
MAX_CHAT_KEYWORDS);
break;
}
String k = key == null ? "" : key.trim().toLowerCase();
if (k.isEmpty()) continue;
try {
Pattern pattern = Pattern.compile("\\b" + Pattern.quote(k) + "\\b");
chatResponses.put(pattern, shared);
} catch (Exception e) {
LOGGER.error("Failed to compile Frank chat keyword pattern: {}", k, e);
}
}
}
} catch (SQLException e) {
LOGGER.warn("FrankBot: could not load bot_chat_responses ({}). Frank will still serve items.", e.getMessage());
}
ButlerBot.initialise();
}
public static void dispose() {
chatResponses.clear();
doorLines = DEFAULT_DOOR_LINES;
busyWhisper = DEFAULT_BUSY_WHISPER;
doorTriggerPattern = DEFAULT_DOOR_PATTERN;
ButlerBot.dispose();
}
private static Pattern buildDoorTriggerPattern(List<String> triggers) {
StringBuilder sb = new StringBuilder("\\b(");
boolean first = true;
int count = 0;
for (String trigger : triggers) {
if (count >= MAX_DOOR_TRIGGERS) {
LOGGER.warn("FrankBot: door trigger cap ({}) reached, extra entries ignored",
MAX_DOOR_TRIGGERS);
break;
}
String t = trigger == null ? "" : trigger.trim().toLowerCase();
if (t.isEmpty()) continue;
if (!first) sb.append('|');
sb.append(Pattern.quote(t));
first = false;
count++;
}
sb.append(")\\b");
if (first) return DEFAULT_DOOR_PATTERN;
try {
return Pattern.compile(sb.toString());
} catch (Exception e) {
LOGGER.error("FrankBot: failed to compile door trigger pattern from {}, falling back to default", triggers, e);
return DEFAULT_DOOR_PATTERN;
}
}
@Override
public void onUserSay(final RoomChatMessage message) {
Room currentRoom = this.getRoom();
if (currentRoom == null) return;
Habbo asker = message.getHabbo();
if (asker == null || asker.getClient() == null) return;
if (this.getRoomUnit() == null) return;
String raw = message.getUnfilteredMessage();
if (raw != null && raw.length() > MAX_MESSAGE_LEN) return;
if (this.homeTile == null) {
this.homeTile = this.getRoomUnit().getCurrentLocation();
this.homeRotation = this.getRoomUnit().getBodyRotation();
}
if (this.busy.get() || this.getRoomUnit().hasStatus(RoomUnitStatus.MOVE)) {
this.whisperThrottled(asker, busyWhisper);
return;
}
if (raw != null) {
double distance = this.getRoomUnit().getCurrentLocation().distance(asker.getRoomUnit().getCurrentLocation());
int commandDistance = Emulator.getConfig().getInt("hotel.bot.butler.commanddistance");
if (distance <= commandDistance) {
String lower = raw.toLowerCase();
if (doorTriggerPattern.matcher(lower).find()) {
if (!this.busy.compareAndSet(false, true)) {
this.whisperThrottled(asker, busyWhisper);
return;
}
this.showToTheDoor(asker);
return;
}
for (java.util.Map.Entry<Pattern, List<String>> entry : chatResponses.entrySet()) {
if (entry.getKey().matcher(lower).find()) {
List<String> options = entry.getValue();
if (options.isEmpty()) continue;
String reply = options.get(RANDOM.nextInt(options.size()));
this.talk(reply);
return;
}
}
}
}
if (!this.busy.compareAndSet(false, true)) {
this.whisperThrottled(asker, busyWhisper);
return;
}
super.onUserSay(message);
this.schedulePostServeReturn(currentRoom.getId(), 0);
}
private void whisperThrottled(Habbo target, String text) {
if (target == null || text == null || text.isEmpty() || this.getRoomUnit() == null) return;
int userId = target.getHabboInfo().getId();
long now = System.currentTimeMillis();
Long last = lastBusyWhisperAt.get(userId);
if (last != null && (now - last) < BUSY_WHISPER_COOLDOWN_MS) return;
lastBusyWhisperAt.put(userId, now);
RoomChatMessage msg = new RoomChatMessage(text, this.getRoomUnit(), RoomChatMessageBubbles.BOT);
target.getClient().sendResponse(new RoomUserWhisperComposer(msg));
}
private void showToTheDoor(final Habbo target) {
final Room room = this.getRoom();
if (room == null || room.getLayout() == null || target == null) {
this.busy.set(false);
return;
}
final RoomTile doorTile = room.getLayout().getDoorTile();
if (doorTile == null) {
this.busy.set(false);
return;
}
this.lookAt(target);
List<String> lines = doorLines;
String line = lines.isEmpty() ? DEFAULT_DOOR_LINES.get(RANDOM.nextInt(DEFAULT_DOOR_LINES.size()))
: lines.get(RANDOM.nextInt(lines.size()));
this.talk(line);
final int targetId = target.getHabboInfo().getId();
final int roomId = room.getId();
final AtomicBoolean fired = new AtomicBoolean(false);
final Runnable kickThenReturn = () -> {
if (!fired.compareAndSet(false, true)) return;
Room currentRoom = this.getRoom();
if (currentRoom == null || currentRoom.getId() != roomId) {
this.busy.set(false);
return;
}
Habbo stillHere = currentRoom.getHabbo(targetId);
if (stillHere != null) {
currentRoom.kickHabbo(stillHere, false);
}
this.scheduleReturnHome(targetId, roomId, 0);
};
if (this.getRoomUnit().canWalk() && !this.getRoomUnit().getCurrentLocation().equals(doorTile)) {
List<Runnable> onArrive = new ArrayList<>();
onArrive.add(kickThenReturn);
List<Runnable> onFail = new ArrayList<>();
onFail.add(() -> Emulator.getThreading().run(kickThenReturn, 1500));
this.getRoomUnit().setGoalLocation(doorTile);
Emulator.getThreading().run(
new RoomUnitWalkToLocation(this.getRoomUnit(), doorTile, room, onArrive, onFail));
} else {
Emulator.getThreading().run(kickThenReturn, 1500);
}
}
private static final int RETURN_HOME_POLL_MS = 500;
private static final int RETURN_HOME_MAX_WAIT_MS = 8000;
private static final int POST_SERVE_POLL_MS = 750;
private static final int POST_SERVE_MAX_WAIT_MS = 30000;
private void schedulePostServeReturn(final int roomId, final int waitedMs) {
if (waitedMs == 0 && !this.returnScheduled.compareAndSet(false, true)) {
return;
}
if (waitedMs >= POST_SERVE_MAX_WAIT_MS) {
this.returnScheduled.set(false);
this.busy.set(false);
return;
}
if (this.homeTile == null) {
this.returnScheduled.set(false);
this.busy.set(false);
return;
}
Emulator.getThreading().run(() -> {
Room r = this.getRoom();
if (r == null || r.getId() != roomId || this.getRoomUnit() == null || this.homeTile == null) {
this.returnScheduled.set(false);
this.busy.set(false);
return;
}
if (this.getRoomUnit().getCurrentLocation().equals(this.homeTile)) {
if (this.homeRotation != null && this.getRoomUnit().getBodyRotation() != this.homeRotation) {
this.getRoomUnit().setRotation(this.homeRotation);
r.sendComposer(new RoomUserStatusComposer(this.getRoomUnit()).compose());
this.persistPosition();
} else {
this.busy.set(false);
}
this.returnScheduled.set(false);
return;
}
boolean stillWalking = this.getRoomUnit().hasStatus(RoomUnitStatus.MOVE)
|| (this.getRoomUnit().getPath() != null && !this.getRoomUnit().getPath().isEmpty());
if (stillWalking) {
this.schedulePostServeReturn(roomId, waitedMs + POST_SERVE_POLL_MS);
return;
}
this.returnScheduled.set(false);
this.returnHome(-1, false);
}, POST_SERVE_POLL_MS);
}
private void scheduleReturnHome(final int kickedHabboId, final int roomId, final int waitedMs) {
Room currentRoom = this.getRoom();
if (currentRoom == null || currentRoom.getId() != roomId) return;
boolean stillEscorting = currentRoom.getHabbo(kickedHabboId) != null;
if (!stillEscorting || waitedMs >= RETURN_HOME_MAX_WAIT_MS) {
this.returnHome(kickedHabboId, true);
return;
}
Emulator.getThreading().run(
() -> this.scheduleReturnHome(kickedHabboId, roomId, waitedMs + RETURN_HOME_POLL_MS),
RETURN_HOME_POLL_MS);
}
private void returnHome(int kickedHabboId, boolean alwaysTeleport) {
final Room room = this.getRoom();
if (room == null || this.homeTile == null || this.getRoomUnit() == null) {
this.busy.set(false);
return;
}
final Runnable teleportHome = () -> {
Room r = this.getRoom();
if (r == null || this.getRoomUnit() == null) return;
double homeZ = r.getTopHeightAt(this.homeTile.x, this.homeTile.y);
this.getRoomUnit().stopWalking();
this.getRoomUnit().setZ(homeZ);
this.getRoomUnit().setLocation(this.homeTile);
this.getRoomUnit().setPreviousLocationZ(homeZ);
if (this.homeRotation != null) {
this.getRoomUnit().setRotation(this.homeRotation);
}
this.getRoomUnit().statusUpdate(true);
r.sendComposer(new RoomUserStatusComposer(this.getRoomUnit()).compose());
this.persistPosition();
};
if (this.getRoomUnit().getCurrentLocation().equals(this.homeTile)) {
if (this.homeRotation != null) {
this.getRoomUnit().setRotation(this.homeRotation);
room.sendComposer(new RoomUserStatusComposer(this.getRoomUnit()).compose());
}
this.persistPosition();
return;
}
boolean hasOtherWatchers = false;
for (Habbo h : room.getCurrentHabbos().values()) {
if (h.getHabboInfo().getId() != kickedHabboId) {
hasOtherWatchers = true;
break;
}
}
if (alwaysTeleport || !hasOtherWatchers || !this.getRoomUnit().canWalk()) {
teleportHome.run();
return;
}
List<Runnable> onArrive = new ArrayList<>();
onArrive.add(() -> {
if (this.homeRotation != null && this.getRoom() != null) {
this.getRoomUnit().setRotation(this.homeRotation);
this.getRoom().sendComposer(new RoomUserStatusComposer(this.getRoomUnit()).compose());
}
this.persistPosition();
});
List<Runnable> onFail = new ArrayList<>();
onFail.add(teleportHome);
this.getRoomUnit().setGoalLocation(this.homeTile);
Emulator.getThreading().run(
new RoomUnitWalkToLocation(this.getRoomUnit(), this.homeTile, room, onArrive, onFail));
}
private void persistPosition() {
this.needsUpdate(true);
this.run();
this.busy.set(false);
}
}
@@ -202,6 +202,8 @@ public class CatalogManager {
public final Item ecotronItem;
public final THashMap<Integer, CatalogLimitedConfiguration> limitedNumbers;
private final List<Voucher> vouchers;
public final TIntObjectMap<int[]> furnitureValues;
private volatile byte[] rareValuesPayloadCache;
public CatalogManager() {
long millis = System.currentTimeMillis();
@@ -219,6 +221,7 @@ public class CatalogManager {
this.buildersClubOfferDefs = new TIntIntHashMap();
this.vouchers = new ArrayList<>();
this.limitedNumbers = new THashMap<>();
this.furnitureValues = new TIntObjectHashMap<>();
this.initialize();
@@ -243,6 +246,76 @@ public class CatalogManager {
this.loadClothing();
this.loadRecycler();
this.loadGiftWrappers();
this.loadFurnitureValues();
}
private synchronized void loadFurnitureValues() {
this.furnitureValues.clear();
final int diamondType = Emulator.getConfig().getInt("seasonal.currency.diamond", 5);
for (CatalogPage page : this.catalogPages.valueCollection()) {
for (CatalogItem catalogItem : page.getCatalogItems().valueCollection()) {
if (catalogItem.getAmount() != 1)
continue;
int credits = catalogItem.getCredits();
int points = catalogItem.getPoints();
int pointsType = catalogItem.getPointsType();
if (points <= 0 || pointsType != diamondType)
continue;
THashSet<Item> baseItems = catalogItem.getBaseItems();
if (baseItems.size() != 1)
continue;
for (Item item : baseItems) {
FurnitureType type = item.getType();
if (type != FurnitureType.FLOOR && type != FurnitureType.WALL)
continue;
int spriteId = item.getSpriteId();
if (spriteId > 0 && !this.furnitureValues.containsKey(spriteId)) {
this.furnitureValues.put(spriteId, new int[]{credits, points, pointsType});
}
}
}
}
this.rebuildRareValuesPayloadCache();
LOGGER.info("Furniture Values -> Loaded! ({} entries)", this.furnitureValues.size());
}
private void rebuildRareValuesPayloadCache() {
try (java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(this.furnitureValues.size() * 16 + 8);
java.io.DataOutputStream out = new java.io.DataOutputStream(baos)) {
out.writeInt(this.furnitureValues.size());
TIntObjectIterator<int[]> iterator = this.furnitureValues.iterator();
while (iterator.hasNext()) {
iterator.advance();
int[] value = iterator.value();
out.writeInt(iterator.key()); // spriteId
out.writeInt(value[0]); // credits
out.writeInt(value[1]); // points
out.writeInt(value[2]); // pointsType
}
this.rareValuesPayloadCache = baos.toByteArray();
} catch (java.io.IOException e) {
LOGGER.error("Failed to build rare values payload cache", e);
this.rareValuesPayloadCache = null;
}
}
public TIntObjectMap<int[]> getFurnitureValues() {
return this.furnitureValues;
}
public byte[] getRareValuesPayloadSnapshot() {
return this.rareValuesPayloadCache;
}
private synchronized void loadLimitedNumbers() {
@@ -981,13 +1054,13 @@ public class CatalogManager {
if (Emulator.getConfig().getBoolean("hotel.catalog.ltd.limit.enabled")) {
int ltdLimit = Emulator.getConfig().getInt("hotel.purchase.ltd.limit.daily.total");
if (habbo.getHabboStats().totalLtds() >= ltdLimit) {
habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.total").replace("%itemname%", item.getBaseItems().iterator().next().getFullName()).replace("%limit%", ltdLimit + ""));
habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.total").replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName()).replace("%limit%", ltdLimit + ""));
return;
}
ltdLimit = Emulator.getConfig().getInt("hotel.purchase.ltd.limit.daily.item");
if (habbo.getHabboStats().totalLtds(item.id) >= ltdLimit) {
habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.item").replace("%itemname%", item.getBaseItems().iterator().next().getFullName()).replace("%limit%", ltdLimit + ""));
habbo.alert(Emulator.getTexts().getValue("error.catalog.buy.limited.daily.item").replace("%itemname%", item.getBaseItems().iterator().next().getDisplayName()).replace("%limit%", ltdLimit + ""));
return;
}
}
@@ -1046,10 +1119,19 @@ public class CatalogManager {
for (Item baseItem : item.getBaseItems()) {
for (int k = 0; k < item.getItemAmount(baseItem.getId()); k++) {
if (baseItem.getName().startsWith("rentable_bot_") || baseItem.getName().startsWith("bot_")) {
String baseName = baseItem.getName();
String type = item.getName().replace("rentable_bot_", "");
type = type.replace("bot_", "");
type = type.replace("visitor_logger", "visitor_log");
if (("bot_" + com.eu.habbo.habbohotel.bots.FrankBot.BOT_TYPE).equals(baseName)
|| ("rentable_bot_" + com.eu.habbo.habbohotel.bots.FrankBot.BOT_TYPE).equals(baseName)) {
if (!habbo.getClient().getHabbo().hasPermission(com.eu.habbo.habbohotel.bots.FrankBot.PERMISSION_USE)) {
habbo.getClient().sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR).compose());
return;
}
}
THashMap<String, String> data = new THashMap<>();
for (String s : item.getExtradata().split(";")) {
@@ -1165,6 +1247,11 @@ public class CatalogManager {
Guild guild = Emulator.getGameEnvironment().getGuildManager().getGuild(guildId);
if (guild != null && Emulator.getGameEnvironment().getGuildManager().getGuildMember(guild, habbo) != null) {
if (baseItem.getName().equals("guild_forum") && guild.getOwnerId() != habbo.getHabboInfo().getId()) {
habbo.getClient().sendResponse(new AlertPurchaseFailedComposer(AlertPurchaseFailedComposer.SERVER_ERROR));
return;
}
InteractionGuildFurni habboItem = (InteractionGuildFurni) Emulator.getGameEnvironment().getItemManager().createItem(habbo.getClient().getHabbo().getHabboInfo().getId(), baseItem, limitedStack, limitedNumber, extradata);
habboItem.setExtradata("");
habboItem.needsUpdate(true);
@@ -42,14 +42,18 @@ public class RoomBundleLayout extends SingleBundle {
}
if (this.room == null) {
if (this.roomId > 0) {
this.room = Emulator.getGameEnvironment().getRoomManager().loadRoom(this.roomId);
RoomManager roomManager = Emulator.getGameEnvironment().getRoomManager();
if (this.roomId > 0 && roomManager != null) {
this.room = roomManager.loadRoom(this.roomId);
if (this.room != null)
this.room.preventUnloading = true;
} else {
} else if (this.roomId <= 0) {
LOGGER.error("No room id specified for room bundle {}({})", this.getPageName(), this.getId());
}
// roomManager can be null when CatalogManager.loadFurnitureValues() runs
// during GameEnvironment.load() before RoomManager is constructed; in that
// case skip eager room loading the bundle resolves lazily at runtime.
}
if (this.room == null) {
@@ -171,8 +171,9 @@ public class MarketPlace {
statement.setInt(paramIndex++, maxPrice);
}
if (!search.isEmpty()) {
statement.setString(paramIndex++, "%" + search + "%");
statement.setString(paramIndex++, "%" + search + "%");
String likeSearch = "%" + com.eu.habbo.util.SqlLikeEscaper.escape(search) + "%";
statement.setString(paramIndex++, likeSearch);
statement.setString(paramIndex++, likeSearch);
}
try (ResultSet set = statement.executeQuery()) {
@@ -278,8 +279,9 @@ public class MarketPlace {
return;
}
int soldTimestamp = Emulator.getIntUnixTimestamp();
try (PreparedStatement updateOffer = connection.prepareStatement("UPDATE marketplace_items SET state = 2, sold_timestamp = ? WHERE id = ? AND state = 1")) {
updateOffer.setInt(1, Emulator.getIntUnixTimestamp());
updateOffer.setInt(1, soldTimestamp);
updateOffer.setInt(2, offerId);
int updated = updateOffer.executeUpdate();
if (updated == 0) {
@@ -306,7 +308,11 @@ public class MarketPlace {
client.sendResponse(new MarketplaceBuyErrorComposer(MarketplaceBuyErrorComposer.REFRESH, 0, offerId, price));
if (habbo != null) {
habbo.getInventory().getOffer(offerId).setState(MarketPlaceState.SOLD);
MarketPlaceOffer offer = habbo.getInventory().getOffer(offerId);
if (offer != null) {
offer.setState(MarketPlaceState.SOLD);
offer.setSoldTimestamp(soldTimestamp);
}
}
}
}
@@ -368,6 +374,11 @@ public class MarketPlace {
event.item.setFromGift(false);
MarketPlaceOffer offer = new MarketPlaceOffer(event.item, event.price, client.getHabbo());
if (!offer.isPersisted()) {
LOGGER.warn("Marketplace offer insert failed for user {} item {}", client.getHabbo().getHabboInfo().getId(), event.item.getId());
return false;
}
client.getHabbo().getInventory().addMarketplaceOffer(offer);
client.getHabbo().getInventory().getItemsComponent().removeHabboItem(event.item);
item.setUserId(-1);
@@ -98,6 +98,10 @@ public class MarketPlaceOffer implements Runnable {
return this.offerId;
}
public boolean isPersisted() {
return this.offerId > 0;
}
public void setOfferId(int offerId) {
this.offerId = offerId;
}
@@ -9,6 +9,8 @@ import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.habbohotel.users.HabboInfo;
import com.eu.habbo.habbohotel.users.HabboManager;
import java.util.List;
public class BanCommand extends Command {
public BanCommand() {
super("cmd_ban", Emulator.getTexts().getValue("commands.keys.cmd_ban").split(";"));
@@ -72,7 +74,13 @@ public class BanCommand extends Command {
}
}
ModToolBan ban = Emulator.getGameEnvironment().getModToolManager().ban(target.getId(), gameClient.getHabbo(), reason.toString(), banTime, ModToolBanType.ACCOUNT, -1).get(0);
List<ModToolBan> bans = Emulator.getGameEnvironment().getModToolManager().ban(target.getId(), gameClient.getHabbo(), reason.toString(), banTime, ModToolBanType.ACCOUNT, -1);
if (bans == null || bans.isEmpty()) {
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_ban.user_offline"), RoomChatMessageBubbles.ALERT);
return true;
}
ModToolBan ban = bans.get(0);
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.succes.cmd_ban.ban_issued").replace("%user%", target.getUsername()).replace("%time%", ban.expireDate - Emulator.getIntUnixTimestamp() + "").replace("%reason%", ban.reason), RoomChatMessageBubbles.ALERT);
return true;
@@ -191,11 +191,14 @@ public class CommandHandler {
addCommand(new CreditsCommand());
addCommand(new DanceCommand());
addCommand(new DiagonalCommand());
addCommand(new DisableMassMentionsCommand());
addCommand(new DisableMentionsCommand());
addCommand(new DisconnectCommand());
addCommand(new EjectAllCommand());
addCommand(new EmptyInventoryCommand());
addCommand(new EmptyBotsInventoryCommand());
addCommand(new EmptyPetsInventoryCommand());
addCommand(new EmuStatsCommand());
addCommand(new EnableCommand());
addCommand(new EventCommand());
addCommand(new FacelessCommand());
@@ -300,7 +303,6 @@ public class CommandHandler {
addCommand(new GivePrefixCommand());
addCommand(new ListPrefixesCommand());
addCommand(new RemovePrefixCommand());
addCommand(new PrefixBlacklistCommand());
addCommand(new WiredCommand());
addCommand(new TestCommand());
}
@@ -17,7 +17,22 @@ public class CommandsCommand extends Command {
message.append("(").append(commands.size()).append("):\r\n");
for (Command c : commands) {
message.append(Emulator.getTexts().getValue("commands.description." + c.permission, "commands.description." + c.permission)).append("\r");
String textKey = "commands.description." + c.permission;
String commandText = Emulator.getTexts().getValue(textKey, "");
String commandLine = ":" + c.keys[0];
String description = "";
if (commandText.startsWith(":")) {
commandLine = commandText;
} else if (!commandText.isEmpty() && !commandText.equals(textKey)) {
description = commandText;
}
message.append(commandLine).append("\r");
if (!description.isEmpty()) {
message.append(description).append("\r");
}
}
gameClient.getHabbo().alert(new String[]{message.toString()});
@@ -0,0 +1,25 @@
package com.eu.habbo.habbohotel.commands;
import com.eu.habbo.habbohotel.gameclients.GameClient;
import com.eu.habbo.habbohotel.users.Habbo;
public class DisableMassMentionsCommand extends Command {
public DisableMassMentionsCommand() {
super("cmd_disablemassmentions", new String[]{"disablemassmentions", "togglemassmentions"});
}
@Override
public boolean handle(GameClient gameClient, String[] params) throws Exception {
if (gameClient == null) return true;
Habbo habbo = gameClient.getHabbo();
if (habbo == null || habbo.getHabboStats() == null) return true;
boolean newState = !habbo.getHabboStats().massMentionsEnabled();
habbo.getHabboStats().setMassMentionsEnabled(newState);
habbo.whisper(newState
? "Broadcast mentions (@all / @friends / @room) are now ENABLED for you."
: "Broadcast mentions (@all / @friends / @room) are now DISABLED for you. Direct @nick mentions still work.");
return true;
}
}
@@ -0,0 +1,25 @@
package com.eu.habbo.habbohotel.commands;
import com.eu.habbo.habbohotel.gameclients.GameClient;
import com.eu.habbo.habbohotel.users.Habbo;
public class DisableMentionsCommand extends Command {
public DisableMentionsCommand() {
super("cmd_disablementions", new String[]{"disablementions", "togglementions"});
}
@Override
public boolean handle(GameClient gameClient, String[] params) throws Exception {
if (gameClient == null) return true;
Habbo habbo = gameClient.getHabbo();
if (habbo == null || habbo.getHabboStats() == null) return true;
boolean newState = !habbo.getHabboStats().mentionsEnabled();
habbo.getHabboStats().setMentionsEnabled(newState);
habbo.whisper(newState
? "@mention notifications are now ENABLED for you."
: "@mention notifications are now DISABLED for you. You will not receive direct or broadcast mentions.");
return true;
}
}
@@ -0,0 +1,16 @@
package com.eu.habbo.habbohotel.commands;
import com.eu.habbo.habbohotel.gameclients.GameClient;
import com.eu.habbo.habbohotel.permissions.Permission;
public class EmuStatsCommand extends Command {
public EmuStatsCommand() {
super(Permission.ACC_MODTOOL_ROOM_INFO, new String[]{"emustats"});
}
@Override
public boolean handle(GameClient gameClient, String[] params) {
gameClient.getHabbo().whisper("Emulator stats are available in the Nitro stats window.");
return true;
}
}
@@ -4,6 +4,7 @@ import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.gameclients.GameClient;
import com.eu.habbo.habbohotel.modtool.WordFilter;
import com.eu.habbo.habbohotel.modtool.WordFilterWord;
import com.eu.habbo.habbohotel.rooms.RoomChatMessageBubbles;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -21,30 +22,44 @@ public class FilterWordCommand extends Command {
@Override
public boolean handle(GameClient gameClient, String[] params) throws Exception {
if (params.length < 2) {
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_filterword.missing_word"));
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_filterword.missing_word"), RoomChatMessageBubbles.ALERT);
return true;
}
String word = params[1];
// Optional trailing "prefix" keyword marks the word as prefix-only (blocks
// custom prefixes but not chat/motto/guild). Usage:
// :filterword <word> -> everywhere, default replacement
// :filterword <word> <replacement> -> everywhere
// :filterword <word> prefix -> prefix-only, default replacement
// :filterword <word> <replacement> prefix -> prefix-only
boolean prefixOnly = false;
String replacement = WordFilter.DEFAULT_REPLACEMENT;
if (params.length == 3) {
replacement = params[2];
if (params.length >= 3) {
if (params[params.length - 1].equalsIgnoreCase("prefix")) {
prefixOnly = true;
if (params.length >= 4) replacement = params[2];
} else {
replacement = params[2];
}
}
WordFilterWord wordFilterWord = new WordFilterWord(word, replacement);
WordFilterWord wordFilterWord = new WordFilterWord(word, replacement, prefixOnly);
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO wordfilter (`key`, `replacement`) VALUES (?, ?)")) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("INSERT INTO wordfilter (`key`, `replacement`, `prefix_only`) VALUES (?, ?, ?)")) {
statement.setString(1, word);
statement.setString(2, replacement);
statement.setString(3, prefixOnly ? "1" : "0");
statement.execute();
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_filterword.error"));
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_filterword.error"), RoomChatMessageBubbles.ALERT);
return true;
}
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.succes.cmd_filterword.added").replace("%word%", word).replace("%replacement%", replacement));
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.succes.cmd_filterword.added").replace("%word%", word).replace("%replacement%", replacement) + (prefixOnly ? " [prefix-only]" : ""), RoomChatMessageBubbles.ALERT);
Emulator.getGameEnvironment().getWordFilter().addWord(wordFilterWord);
return true;
@@ -8,6 +8,8 @@ import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.habbohotel.users.HabboInfo;
import com.eu.habbo.habbohotel.users.HabboManager;
import java.util.List;
public class IPBanCommand extends Command {
public final static int TEN_YEARS = 315569260;
@@ -50,12 +52,12 @@ public class IPBanCommand extends Command {
return true;
}
Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1);
count++;
List<?> bans = Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1);
count += bans != null ? bans.size() : 0;
for (Habbo h : Emulator.getGameServer().getGameClientManager().getHabbosWithIP(habbo.getIpLogin())) {
if (h != null) {
count++;
Emulator.getGameEnvironment().getModToolManager().ban(h.getHabboInfo().getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1);
bans = Emulator.getGameEnvironment().getModToolManager().ban(h.getHabboInfo().getId(), gameClient.getHabbo(), reason.toString(), TEN_YEARS, ModToolBanType.IP, -1);
count += bans != null ? bans.size() : 0;
}
}
} else {
@@ -8,6 +8,8 @@ import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.habbohotel.users.HabboInfo;
import com.eu.habbo.habbohotel.users.HabboManager;
import java.util.List;
public class MachineBanCommand extends Command {
public MachineBanCommand() {
super("cmd_machine_ban", Emulator.getTexts().getValue("commands.keys.cmd_machine_ban").split(";"));
@@ -46,7 +48,8 @@ public class MachineBanCommand extends Command {
return true;
}
count = Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), IPBanCommand.TEN_YEARS, ModToolBanType.MACHINE, -1).size();
List<?> bans = Emulator.getGameEnvironment().getModToolManager().ban(habbo.getId(), gameClient.getHabbo(), reason.toString(), IPBanCommand.TEN_YEARS, ModToolBanType.MACHINE, -1);
count = bans != null ? bans.size() : 0;
} else {
@@ -58,4 +61,4 @@ public class MachineBanCommand extends Command {
return true;
}
}
}
@@ -1,98 +0,0 @@
package com.eu.habbo.habbohotel.commands;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.gameclients.GameClient;
import com.eu.habbo.habbohotel.rooms.RoomChatMessageBubbles;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class PrefixBlacklistCommand extends Command {
private static final Logger LOGGER = LoggerFactory.getLogger(PrefixBlacklistCommand.class);
public PrefixBlacklistCommand() {
super("cmd_prefix_blacklist", Emulator.getTexts().getValue("commands.keys.cmd_prefix_blacklist").split(";"));
}
@Override
public boolean handle(GameClient gameClient, String[] params) throws Exception {
if (params.length < 2) {
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_prefix_blacklist.usage"), RoomChatMessageBubbles.ALERT);
return true;
}
String action = params[1].toLowerCase();
if (action.equals("list")) {
StringBuilder sb = new StringBuilder();
sb.append(Emulator.getTexts().getValue("commands.succes.cmd_prefix_blacklist.header")).append("\r");
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT word FROM custom_prefix_blacklist ORDER BY word")) {
try (ResultSet set = statement.executeQuery()) {
int count = 0;
while (set.next()) {
sb.append("- ").append(set.getString("word")).append("\r");
count++;
}
if (count == 0) {
sb.append(Emulator.getTexts().getValue("commands.succes.cmd_prefix_blacklist.empty"));
}
}
} catch (SQLException e) {
LOGGER.error("Error listing prefix blacklist", e);
}
gameClient.getHabbo().whisper(sb.toString(), RoomChatMessageBubbles.ALERT);
return true;
}
if (params.length < 3) {
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_prefix_blacklist.usage"), RoomChatMessageBubbles.ALERT);
return true;
}
String word = params[2].toLowerCase().trim();
if (word.isEmpty()) {
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_prefix_blacklist.empty_word"), RoomChatMessageBubbles.ALERT);
return true;
}
if (action.equals("add")) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("INSERT INTO custom_prefix_blacklist (word) VALUES (?)")) {
statement.setString(1, word);
statement.execute();
} catch (SQLException e) {
LOGGER.error("Error adding prefix blacklist word", e);
}
gameClient.getHabbo().whisper(
Emulator.getTexts().getValue("commands.succes.cmd_prefix_blacklist.added").replace("%word%", word),
RoomChatMessageBubbles.ALERT
);
} else if (action.equals("remove")) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("DELETE FROM custom_prefix_blacklist WHERE word = ?")) {
statement.setString(1, word);
statement.execute();
} catch (SQLException e) {
LOGGER.error("Error removing prefix blacklist word", e);
}
gameClient.getHabbo().whisper(
Emulator.getTexts().getValue("commands.succes.cmd_prefix_blacklist.removed").replace("%word%", word),
RoomChatMessageBubbles.ALERT
);
} else {
gameClient.getHabbo().whisper(Emulator.getTexts().getValue("commands.error.cmd_prefix_blacklist.usage"), RoomChatMessageBubbles.ALERT);
}
return true;
}
}
@@ -149,13 +149,23 @@ public class GameClient {
}
public void dispose() {
this.dispose(true);
}
public void dispose(boolean allowSessionResume) {
try {
this.channel.close();
if (this.habbo != null) {
if (this.habbo.isOnline()) {
// Agisci sull'Habbo SOLO se è ancora attaccato a QUESTO client. Su un
// reconnect veloce (drop Cloudflare il client riconnette) l'Habbo può
// essere già stato riassegnato alla NUOVA connessione (session resume):
// in quel caso questo dispose della vecchia connessione NON deve
// parcheggiarlo disconnetterlo, altrimenti ucciderebbe la sessione
// appena ripristinata (era la causa del "Bye"/kick al 2° reconnect).
if (this.habbo.getClient() == this && this.habbo.isOnline()) {
// Try to park the habbo in the grace period instead of immediate disconnect
boolean parked = SessionResumeManager.getInstance().parkHabbo(this.habbo, this.ssoTicket);
boolean parked = allowSessionResume && SessionResumeManager.getInstance().parkHabbo(this.habbo, this.ssoTicket);
if (!parked) {
// No grace period configured immediate disconnect as before
@@ -171,4 +181,4 @@ public class GameClient {
LOGGER.error("Caught exception", e);
}
}
}
}
@@ -43,14 +43,34 @@ public class GameClientManager {
public void disposeClient(GameClient client) {
this.disposeClient(client.getChannel());
if (client == null) {
return;
}
this.disposeClient(client.getChannel(), true);
}
public void forceDisposeClient(GameClient client) {
if (client == null) {
return;
}
this.disposeClient(client.getChannel(), false);
}
private void disposeClient(Channel channel) {
this.disposeClient(channel, true);
}
private void disposeClient(Channel channel, boolean allowSessionResume) {
if (channel == null) {
return;
}
GameClient client = channel.attr(GameServerAttributes.CLIENT).get();
if (client != null) {
client.dispose();
client.dispose(allowSessionResume);
}
channel.deregister();
channel.attr(GameServerAttributes.CLIENT).set(null);
@@ -190,4 +210,4 @@ public class GameClientManager {
CFKeepAlive();
}, 30000);
}
}
}
@@ -71,6 +71,15 @@ public class SessionResumeManager {
}
}, graceSeconds * 1000);
if (future == null) {
// The scheduler refused the grace-expiry task (pool saturated or
// shutting down). Parking now would leave a GhostSession that nothing
// can ever reap (the Habbo + room refs pinned for the JVM lifetime),
// so disconnect immediately instead.
performFullDisconnect(habbo);
return false;
}
ghostSessions.put(userId, new GhostSession(habbo, ssoTicket, future, previousEffectId, previousEffectEnd));
applyPausedEffect(habbo);
@@ -118,16 +127,32 @@ public class SessionResumeManager {
LOGGER.error("[SessionResume] Error during deferred disconnect", e);
}
clearSsoTicket(habbo.getHabboInfo().getId());
// NON svuotare il ticket SSO qui. Dietro Cloudflare la pagina si ricarica
// lentamente (~15s) e la grace (5s) scade prima che la nuova connessione
// arrivi: svuotando il ticket si cancellava quello NUOVO appena scritto dal
// CMS per il refresh "non-existing SSO token" bisognava refreshare 2 volte.
// Il ticket vive col suo TTL (auth_ticket_expires_at) e viene sovrascritto dal
// CMS al prossimo /client o azzerato al logout.
}
private void restoreSsoTicket(int userId, String ssoTicket) {
// Restore the old ticket ONLY if no fresh ticket has been written in the
// meantime. On a hard-refresh the CMS writes a NEW auth_ticket for the same
// user before this parking restore runs; without the guard we'd clobber it
// with the old ticket, so the new connection's SSO wouldn't be found and the
// client would get "session expired" on the first attempt. The guard means:
// normal reconnect (ticket cleared to '' after login) -> restore; hard-refresh
// (CMS already wrote a new ticket) -> leave the new ticket untouched.
try (var connection = Emulator.getDatabase().getDataSource().getConnection();
var statement = connection.prepareStatement("UPDATE users SET auth_ticket = ? WHERE id = ? LIMIT 1")) {
var statement = connection.prepareStatement("UPDATE users SET auth_ticket = ? WHERE id = ? AND (auth_ticket = '' OR auth_ticket IS NULL) LIMIT 1")) {
statement.setString(1, ssoTicket);
statement.setInt(2, userId);
statement.execute();
LOGGER.info("[SessionResume] Restored SSO ticket for user {} during grace period", userId);
int updated = statement.executeUpdate();
if (updated > 0) {
LOGGER.info("[SessionResume] Restored SSO ticket for user {} during grace period", userId);
} else {
LOGGER.info("[SessionResume] Skipped SSO restore for user {} — a newer ticket is already present (likely a fresh login/hard-refresh)", userId);
}
} catch (Exception e) {
LOGGER.error("[SessionResume] Failed to restore SSO ticket for user " + userId, e);
}
@@ -208,10 +208,10 @@ public abstract class Game implements Runnable {
this.state = GameState.IDLE;
boolean gamesActive = false;
for (HabboItem timer : room.getFloorItems()) {
if (timer instanceof InteractionGameTimer) {
if (((InteractionGameTimer) timer).isRunning())
gamesActive = true;
for (InteractionGameTimer timer : room.getRoomSpecialTypes().getGameTimers().values()) {
if (timer.isRunning()) {
gamesActive = true;
break;
}
}
@@ -6,49 +6,55 @@ import com.eu.habbo.habbohotel.wired.core.WiredManager;
public class GamePlayer {
private final Habbo habbo;
private GameTeamColors teamColor;
private int score;
private int wiredScore;
public GamePlayer(Habbo habbo, GameTeamColors teamColor) {
this.habbo = habbo;
this.teamColor = teamColor;
}
public void reset() {
this.score = 0;
this.wiredScore = 0;
}
public synchronized void addScore(int amount) {
public void addScore(int amount) {
addScore(amount, false);
}
public synchronized void addScore(int amount, boolean isWired) {
if (habbo.getHabboInfo().getGamePlayer() != null && this.habbo.getHabboInfo().getCurrentGame() != null && this.habbo.getHabboInfo().getCurrentRoom().getGame(this.habbo.getHabboInfo().getCurrentGame()).getTeamForHabbo(this.habbo) != null) {
this.score += amount;
public void addScore(int amount, boolean isWired) {
com.eu.habbo.habbohotel.rooms.Room roomToTrigger = null;
com.eu.habbo.habbohotel.rooms.RoomUnit roomUnitToTrigger = null;
int currentScore = 0;
if (this.score < 0) this.score = 0;
synchronized (this) {
if (this.habbo.getHabboInfo().getGamePlayer() != null && this.habbo.getHabboInfo().getCurrentGame() != null && this.habbo.getHabboInfo().getCurrentRoom().getGame(this.habbo.getHabboInfo().getCurrentGame()).getTeamForHabbo(this.habbo) != null) {
this.score += amount;
if(isWired) {
this.wiredScore += amount;
if (this.score < 0) this.score = 0;
if (this.wiredScore < 0) {
this.wiredScore = 0;
if (isWired) {
this.wiredScore += amount;
if (this.wiredScore < 0) {
this.wiredScore = 0;
}
if (this.wiredScore > this.score) {
this.wiredScore = this.score;
}
}
if (this.wiredScore > this.score) {
this.wiredScore = this.score;
}
roomToTrigger = this.habbo.getHabboInfo().getCurrentRoom();
roomUnitToTrigger = this.habbo.getRoomUnit();
currentScore = this.score;
}
}
WiredManager.triggerScoreAchieved(this.habbo.getHabboInfo().getCurrentRoom(), this.habbo.getRoomUnit(), this.score, amount);
if (roomToTrigger != null && roomUnitToTrigger != null) {
WiredManager.triggerScoreAchieved(roomToTrigger, roomUnitToTrigger, currentScore, amount);
}
}
@@ -56,12 +62,10 @@ public class GamePlayer {
return this.habbo;
}
public GameTeamColors getTeamColor() {
return this.teamColor;
}
public int getScore() {
return this.score;
}
@@ -252,6 +252,25 @@ public class Guild implements Runnable {
return this.readForum;
}
public boolean canHabboReadForum(int habboId, GuildMember member, boolean staff) {
if (staff || this.getOwnerId() == habboId) {
return true;
}
switch (this.readForum) {
case EVERYONE:
return true;
case MEMBERS:
return member != null && member.getRank().type <= GuildRank.MEMBER.type;
case ADMINS:
return member != null && member.getRank().type < GuildRank.MEMBER.type;
case OWNER:
return false;
default:
return true;
}
}
public void setReadForum(SettingsState readForum) {
this.readForum = readForum;
}
@@ -421,9 +421,9 @@ public class GuildManager {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT users.username, users.look, guilds_members.* FROM guilds_members INNER JOIN users ON guilds_members.user_id = users.id WHERE guilds_members.guild_id = ? " + (rankQuery(levelId)) + " AND users.username LIKE ? ORDER BY level_id, member_since ASC LIMIT ?, ?")) {
statement.setInt(1, guild.getId());
statement.setString(2, "%" + query + "%");
statement.setString(2, "%" + com.eu.habbo.util.SqlLikeEscaper.escape(query) + "%");
statement.setInt(3, page * 14);
statement.setInt(4, (page * 14) + 14);
statement.setInt(4, 14);
try (ResultSet set = statement.executeQuery()) {
while (set.next()) {
@@ -101,21 +101,27 @@ public class ForumThread implements Runnable, ISerialize {
if (statement.executeUpdate() < 1)
return null;
ResultSet set = statement.getGeneratedKeys();
if (set.next()) {
int threadId = set.getInt(1);
createdThread = new ForumThread(threadId, guild.getId(), opener.getHabboInfo().getId(), subject, 0, timestamp, timestamp, ForumThreadState.OPEN, false, false, 0, null);
cacheThread(createdThread);
ForumThreadComment comment = ForumThreadComment.create(createdThread, opener, message);
createdThread.addComment(comment);
Emulator.getPluginManager().fireEvent(new GuildForumThreadCreated(createdThread));
try (ResultSet set = statement.getGeneratedKeys()) {
if (set.next()) {
int threadId = set.getInt(1);
createdThread = new ForumThread(threadId, guild.getId(), opener.getHabboInfo().getId(), subject, 0, timestamp, timestamp, ForumThreadState.OPEN, false, false, 0, null);
cacheThread(createdThread);
}
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
// ForumThreadComment.create() opens its OWN connection; do it after the
// thread's connection has been released to avoid holding two pooled
// connections simultaneously per forum-thread creation.
if (createdThread != null) {
ForumThreadComment comment = ForumThreadComment.create(createdThread, opener, message);
createdThread.addComment(comment);
Emulator.getPluginManager().fireEvent(new GuildForumThreadCreated(createdThread));
}
return createdThread;
}
@@ -98,12 +98,13 @@ public class ForumThreadComment implements Runnable, ISerialize {
if (statement.executeUpdate() < 1)
return null;
ResultSet set = statement.getGeneratedKeys();
if (set.next()) {
int commentId = set.getInt(1);
createdComment = new ForumThreadComment(commentId, thread.getThreadId(), poster.getHabboInfo().getId(), message, timestamp, ForumThreadState.OPEN, 0);
try (ResultSet set = statement.getGeneratedKeys()) {
if (set.next()) {
int commentId = set.getInt(1);
createdComment = new ForumThreadComment(commentId, thread.getThreadId(), poster.getHabboInfo().getId(), message, timestamp, ForumThreadState.OPEN, 0);
Emulator.getPluginManager().fireEvent(new GuildForumThreadCommentCreated(createdComment));
Emulator.getPluginManager().fireEvent(new GuildForumThreadCommentCreated(createdComment));
}
}
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
@@ -0,0 +1,8 @@
package com.eu.habbo.habbohotel.items;
/**
* One parsed furnidata entry. {@code classname} is the raw furnidata classname
* (may carry a {@code *N} colour-variant suffix); the provider keys on the base.
*/
public record FurnidataEntry(int id, String classname, FurnitureType type, String name, String description) {
}
@@ -0,0 +1,13 @@
package com.eu.habbo.habbohotel.items;
import java.util.concurrent.locks.ReentrantLock;
/**
* One process-wide lock serializing every furnidata reindex and every editor-driven
* furnidata write, so an editor write never races the file watcher's reindex and the
* volatile index is never observed mid-swap by two writers.
*/
public final class FurnidataLock {
public static final ReentrantLock LOCK = new ReentrantLock();
private FurnidataLock() {}
}
@@ -0,0 +1,172 @@
package com.eu.habbo.habbohotel.items;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Neutral furnidata reader. Supports a single JSON/JSON5 file or a split-tier
* directory ({@code core/custom/seasonal} with {@code manifest.json(5)}).
* Never throws: any IO/parse error yields an empty list (the caller decides the
* fallback). All resolved paths are guarded against escaping the base dir.
*/
public class FurnidataReader {
private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataReader.class);
private static final List<String> DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal");
private static final List<String> MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json");
private static final List<String> SECTIONS = Arrays.asList("roomitemtypes", "wallitemtypes");
private final Path source;
private final long maxBytes;
public FurnidataReader(Path source, long maxBytes) {
this.source = source;
this.maxBytes = maxBytes;
}
public List<FurnidataEntry> read() {
List<FurnidataEntry> out = new ArrayList<>();
try {
if (this.source == null || !Files.exists(this.source)) return out;
if (Files.isDirectory(this.source)) {
readSplitDir(this.source, out);
} else {
String content = readJson5Capped(this.source);
if (content != null) {
parseRoot(JsonParser.parseString(content).getAsJsonObject(), out);
}
}
} catch (Exception e) {
LOGGER.warn("FurnidataReader failed to read {} — returning empty", this.source, e);
return new ArrayList<>();
}
return out;
}
private void readSplitDir(Path base, List<FurnidataEntry> out) {
List<String> tiers = readManifestList(base, "tiers", DEFAULT_TIERS);
Path baseNorm = base.toAbsolutePath().normalize();
for (String tier : tiers) {
Path tierDir = base.resolve(tier);
if (!isInside(baseNorm, tierDir) || !Files.isDirectory(tierDir)) continue;
for (String fileName : readManifestList(tierDir, "files", List.of())) {
Path file = tierDir.resolve(fileName);
if (!isInside(baseNorm, file)) {
LOGGER.warn("FurnidataReader: ignoring out-of-base file {}", file);
continue;
}
if (!Files.exists(file)) continue;
try {
String content = readJson5Capped(file);
if (content != null) parseRoot(JsonParser.parseString(content).getAsJsonObject(), out);
} catch (Exception e) {
LOGGER.warn("FurnidataReader: failed to parse {}", file, e);
}
}
}
}
private List<String> readManifestList(Path dir, String key, List<String> fallback) {
for (String name : MANIFEST_NAMES) {
Path m = dir.resolve(name);
if (!Files.exists(m)) continue;
try {
String raw = readJson5Capped(m);
if (raw == null) continue;
JsonObject obj = JsonParser.parseString(raw).getAsJsonObject();
if (obj.has(key) && obj.get(key).isJsonArray()) {
List<String> list = new ArrayList<>();
for (JsonElement el : obj.getAsJsonArray(key)) list.add(el.getAsString());
if (!list.isEmpty()) return list;
}
} catch (Exception e) {
LOGGER.warn("FurnidataReader: bad manifest {}", m, e);
}
}
return fallback;
}
private void parseRoot(JsonObject root, List<FurnidataEntry> out) {
for (String section : SECTIONS) {
if (!root.has(section)) continue;
JsonObject sectionObj = root.getAsJsonObject(section);
if (!sectionObj.has("furnitype")) continue;
FurnitureType type = section.equals("roomitemtypes") ? FurnitureType.FLOOR : FurnitureType.WALL;
JsonArray types = sectionObj.getAsJsonArray("furnitype");
for (JsonElement el : types) {
JsonObject o = el.getAsJsonObject();
if (!o.has("id") || o.get("id").isJsonNull() || !o.has("classname") || o.get("classname").isJsonNull()) continue;
out.add(new FurnidataEntry(
o.get("id").getAsInt(),
o.get("classname").getAsString(),
type,
(o.has("name") && !o.get("name").isJsonNull()) ? o.get("name").getAsString() : "",
(o.has("description") && !o.get("description").isJsonNull()) ? o.get("description").getAsString() : ""
));
}
}
}
/** Returns the JSON5-stripped content, or null if the file exceeds the byte cap. */
private String readJson5Capped(Path path) throws Exception {
long size = Files.size(path);
if (size > this.maxBytes) {
LOGGER.warn("FurnidataReader: {} is {} bytes, over cap {} — refusing", path, size, this.maxBytes);
return null;
}
return stripJson5(Files.readString(path, StandardCharsets.UTF_8));
}
private static boolean isInside(Path baseNorm, Path candidate) {
return candidate.toAbsolutePath().normalize().startsWith(baseNorm);
}
/**
* Strip // and block comments and trailing commas so Gson can parse JSON5.
* Known limitation: the trailing-comma pass is a regex over the whole output,
* so a string value literally containing ",[whitespace]}" or ",[whitespace]]"
* would be altered. Real Habbo furnidata names/descriptions do not contain
* that pattern; values are additionally sanitized downstream before use.
*/
static String stripJson5(String content) {
if (content == null || content.isEmpty()) return content;
StringBuilder out = new StringBuilder(content.length());
int i = 0, len = content.length();
boolean inString = false, escape = false;
char stringChar = 0;
while (i < len) {
char c = content.charAt(i);
if (inString) {
out.append(c);
if (escape) escape = false;
else if (c == '\\') escape = true;
else if (c == stringChar) inString = false;
i++;
continue;
}
if (c == '"' || c == '\'') { inString = true; stringChar = c; out.append(c); i++; continue; }
if (c == '/' && i + 1 < len) {
char next = content.charAt(i + 1);
if (next == '/') { int eol = content.indexOf('\n', i + 2); if (eol < 0) break; i = eol; continue; }
if (next == '*') { int end = content.indexOf("*/", i + 2); if (end < 0) break; i = end + 2; continue; }
}
out.append(c);
i++;
}
return out.toString().replaceAll(",(\\s*[}\\]])", "$1");
}
}
@@ -0,0 +1,172 @@
package com.eu.habbo.habbohotel.items;
import com.eu.habbo.Emulator;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public final class FurnidataSourceResolver {
private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataSourceResolver.class);
public enum Status {
RESOLVED,
SOURCE_MISSING,
CONFIG_MISSING,
UNRESOLVED_PLACEHOLDER,
ERROR
}
public record Source(Path path, boolean directory, Status status, String message) {
public boolean ok() {
return this.status == Status.RESOLVED && this.path != null && Files.exists(this.path);
}
}
private FurnidataSourceResolver() {
}
public static Source resolve() {
try {
String override = Emulator.getConfig().getValue("items.furnidata.path", "");
if (!override.isEmpty()) {
Path p = Paths.get(override);
if (Files.exists(p)) return new Source(p, Files.isDirectory(p), Status.RESOLVED, "items.furnidata.path");
return new Source(p, Files.isDirectory(p), Status.SOURCE_MISSING, "items.furnidata.path does not exist");
}
String rendererConfigPath = Emulator.getConfig().getValue("furni.editor.renderer.config.path", "");
String assetBasePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", "");
if (!rendererConfigPath.isEmpty()) {
Source fromRenderer = resolveFromRendererConfig(Paths.get(rendererConfigPath), assetBasePath.isEmpty() ? null : Paths.get(assetBasePath));
if (fromRenderer.ok() || fromRenderer.status() == Status.UNRESOLVED_PLACEHOLDER) return fromRenderer;
}
Source fallback = resolveFromAssetBase(assetBasePath);
if (fallback != null) return fallback;
return new Source(null, false, Status.CONFIG_MISSING, "No furnidata source config found");
} catch (Exception e) {
LOGGER.warn("FurnidataSourceResolver failed", e);
return new Source(null, false, Status.ERROR, e.getMessage() != null ? e.getMessage() : "Resolver error");
}
}
public static Source resolveFromRendererConfig(Path rendererConfig, Path assetBase) {
try {
if (rendererConfig == null || !Files.exists(rendererConfig)) {
return new Source(rendererConfig, false, Status.SOURCE_MISSING, "renderer-config path does not exist");
}
String raw = Files.readString(rendererConfig, StandardCharsets.UTF_8);
JsonObject rendererObj = JsonParser.parseString(FurnidataReader.stripJson5(raw)).getAsJsonObject();
String furniUrl = expandRendererUrl(rendererObj, "furnidata.url");
if (furniUrl.isBlank()) return new Source(null, false, Status.CONFIG_MISSING, "furnidata.url is missing");
if (hasUnresolvedPathPlaceholder(furniUrl)) return new Source(null, false, Status.UNRESOLVED_PLACEHOLDER, furniUrl);
Source source = toLocalSource(assetBase, furniUrl);
if (source == null) return new Source(null, false, Status.CONFIG_MISSING, "furni.editor.asset.base.path is missing");
if (!Files.exists(source.path())) return new Source(source.path(), source.directory(), Status.SOURCE_MISSING, "Resolved source does not exist");
return source;
} catch (Exception e) {
return new Source(null, false, Status.ERROR, e.getMessage() != null ? e.getMessage() : "renderer-config parse failed");
}
}
private static Source resolveFromAssetBase(String assetBasePath) {
if (assetBasePath == null || assetBasePath.isEmpty()) return null;
Path dir = Paths.get(assetBasePath);
Path split = dir.resolve("furnidata");
if (Files.isDirectory(split)) return new Source(split, true, Status.RESOLVED, "asset base split furnidata");
Path legacy = dir.resolve("FurnitureData.json");
if (Files.exists(legacy)) return new Source(legacy, false, Status.RESOLVED, "asset base FurnitureData.json");
return new Source(dir, true, Status.SOURCE_MISSING, "No furnidata or FurnitureData.json under asset base");
}
public static String expandRendererUrl(JsonObject rendererObj, String key) {
if (rendererObj == null || !rendererObj.has(key)) return "";
String value = rendererObj.get(key).getAsString();
for (int i = 0; i < 10; i++) {
int start = value.indexOf("${");
if (start < 0) break;
int end = value.indexOf('}', start + 2);
if (end < 0) break;
String placeholder = value.substring(start + 2, end);
if (!rendererObj.has(placeholder)) break;
value = value.substring(0, start) + rendererObj.get(placeholder).getAsString() + value.substring(end + 1);
}
return value;
}
public static Source toLocalSource(Path assetBase, String furniUrl) {
if (furniUrl == null || furniUrl.isBlank()) return null;
String cleanUrl = stripQueryAndFragment(furniUrl);
boolean splitMode = cleanUrl.endsWith("/");
if (!cleanUrl.startsWith("http")) {
Path local = Paths.get(cleanUrl);
return new Source(local, splitMode || Files.isDirectory(local), Status.RESOLVED, "local furnidata.url");
}
if (assetBase == null) return null;
String urlPath;
try {
urlPath = URI.create(cleanUrl).getPath();
} catch (Exception e) {
int scheme = cleanUrl.indexOf("://");
int pathStart = scheme >= 0 ? cleanUrl.indexOf('/', scheme + 3) : -1;
urlPath = pathStart >= 0 ? cleanUrl.substring(pathStart) : cleanUrl;
}
String normalized = urlPath.replace('\\', '/');
String baseName = assetBase.getFileName() != null ? assetBase.getFileName().toString() : "";
String marker = "/" + baseName + "/";
int markerIndex = baseName.isEmpty() ? -1 : normalized.indexOf(marker);
Path candidate;
if (markerIndex >= 0) {
candidate = assetBase.resolve(normalized.substring(markerIndex + marker.length()));
} else if (splitMode) {
String trimmed = normalized.endsWith("/") ? normalized.substring(0, normalized.length() - 1) : normalized;
candidate = assetBase.resolve(trimmed.substring(trimmed.lastIndexOf('/') + 1));
} else {
candidate = assetBase.resolve(normalized.substring(normalized.lastIndexOf('/') + 1));
}
return new Source(candidate, splitMode || Files.isDirectory(candidate), Status.RESOLVED, "renderer-config furnidata.url");
}
private static boolean hasUnresolvedPathPlaceholder(String value) {
if (value == null) return false;
return stripQueryAndFragment(value).contains("${");
}
private static String stripQueryAndFragment(String value) {
String out = value;
int q = out.indexOf('?');
if (q >= 0) out = out.substring(0, q);
int h = out.indexOf('#');
if (h >= 0) out = out.substring(0, h);
return out;
}
}
@@ -0,0 +1,160 @@
package com.eu.habbo.habbohotel.items;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.messages.outgoing.furniture.FurnitureDataReloadComposer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.ClosedWatchServiceException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardWatchEventKinds;
import java.nio.file.WatchKey;
import java.nio.file.WatchService;
import java.util.List;
/**
* Watches the furnidata source on a single daemon thread. On change (debounced),
* re-indexes via the provider and broadcasts only the delta or a compact
* reload-hint when the delta exceeds the cap. A minimum interval throttles bursts.
* For the split-tier directory layout, the base dir AND its immediate
* subdirectories are registered. Never throws out of the loop.
*/
public class FurnidataWatcher {
private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataWatcher.class);
private final FurnitureTextProvider provider;
private final Path watchDir;
private final boolean sourceIsDir;
private final long maxBytes;
private final long debounceMs;
private final long minIntervalMs;
private final int deltaCap;
private volatile boolean running = false;
private volatile WatchService ws;
private long lastBroadcast = 0L;
public FurnidataWatcher(FurnitureTextProvider provider, Path source, long maxBytes) {
this.provider = provider;
this.sourceIsDir = Files.isDirectory(source);
this.watchDir = this.sourceIsDir ? source : source.getParent();
this.maxBytes = maxBytes;
this.debounceMs = Long.parseLong(Emulator.getConfig().getValue("items.furnidata.watch.debounce.ms", "750"));
this.minIntervalMs = Long.parseLong(Emulator.getConfig().getValue("items.furnidata.watch.min.interval.ms", "5000"));
this.deltaCap = Integer.parseInt(Emulator.getConfig().getValue("items.furnidata.delta.cap", "500"));
}
public void start() {
if (this.running || this.watchDir == null) return;
this.running = true;
Thread t = new Thread(this::run, "FurnidataWatcher");
t.setDaemon(true);
t.start();
}
public void stop() {
this.running = false;
WatchService local = this.ws;
if (local != null) {
try { local.close(); } catch (IOException ignored) { }
}
}
private void run() {
try {
this.ws = FileSystems.getDefault().newWatchService();
} catch (IOException e) {
LOGGER.warn("FurnidataWatcher: could not create WatchService", e);
return;
}
try (WatchService service = this.ws) {
registerDirs(service);
while (this.running) {
WatchKey key = service.take();
key.pollEvents();
Thread.sleep(this.debounceMs);
key.pollEvents();
if (!key.reset()) {
LOGGER.warn("FurnidataWatcher: watch key invalidated (directory removed?) — stopping");
break;
}
try {
onChange();
} catch (Exception e) {
LOGGER.warn("FurnidataWatcher: onChange failed", e);
}
}
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
} catch (ClosedWatchServiceException ignored) {
// stop() closed the service normal shutdown
} catch (Exception e) {
LOGGER.warn("FurnidataWatcher stopped", e);
}
}
/** Register the base dir, plus one level of subdirectories for the split-tier layout. */
private void registerDirs(WatchService service) throws IOException {
this.watchDir.register(service, StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE);
if (this.sourceIsDir) {
try (DirectoryStream<Path> ds = Files.newDirectoryStream(this.watchDir)) {
for (Path child : ds) {
if (Files.isDirectory(child)) {
child.register(service, StandardWatchEventKinds.ENTRY_MODIFY,
StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE);
}
}
}
}
}
private void onChange() throws InterruptedException {
// Re-index under the shared furnidata lock so the watcher and editor
// writes never swap the index concurrently. The lock is released before
// the throttle/broadcast below so a slow broadcast can't stall editor saves.
List<FurnidataEntry> delta;
FurnidataLock.LOCK.lock();
try {
Path source = this.provider.getSource();
if (source == null) return;
delta = this.provider.reindex(new FurnidataReader(source, this.maxBytes).read());
} finally {
FurnidataLock.LOCK.unlock();
}
if (delta.isEmpty()) return;
// Min-interval throttle: the index has already been swapped, so we must
// not drop this delta (the next reindex would diff against the updated
// index and never re-emit it). Instead, defer the broadcast until the
// interval elapses. Running on a dedicated daemon thread, sleeping is
// safe; file events arriving meanwhile coalesce into the next cycle.
long sinceLast = System.currentTimeMillis() - this.lastBroadcast;
if (sinceLast < this.minIntervalMs) {
Thread.sleep(this.minIntervalMs - sinceLast);
}
this.lastBroadcast = System.currentTimeMillis();
FurnitureDataReloadComposer composer = (delta.size() > this.deltaCap)
? new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_RELOAD_HINT, List.of())
: new FurnitureDataReloadComposer(FurnitureDataReloadComposer.MODE_DELTA, delta);
broadcast(composer);
LOGGER.info("FurnidataWatcher: broadcast {} ({} entries)",
delta.size() > this.deltaCap ? "reload-hint" : "delta", delta.size());
}
private void broadcast(FurnitureDataReloadComposer composer) {
for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) {
if (habbo.getClient() != null) {
habbo.getClient().sendResponse(composer);
}
}
}
}
@@ -0,0 +1,272 @@
package com.eu.habbo.habbohotel.items;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Comment-preserving, atomic, backed-up writer for furnidata name/description, keyed by
* classname. Supports single-file and split-tier (writes the tier that currently resolves
* the classname). Edit-only: refuses classnames absent from the furnidata.
*/
public class FurnidataWriter {
/** Default tier names in override order (later = higher priority, wins on conflict). */
private static final List<String> DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal");
/** Manifest filenames tried in order (json5 first, plain json second). */
private static final List<String> MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json");
private final Path source; // file (single) or base dir (split-tier)
private final boolean directory; // true => split-tier
private final long maxBytes;
private final int backupKeep;
public FurnidataWriter(Path source, boolean directory, long maxBytes, int backupKeep) {
this.source = source;
this.directory = directory;
this.maxBytes = maxBytes;
this.backupKeep = Math.max(1, backupKeep);
}
/** @return true if an entry for classname was found and written. */
public boolean write(String classname, String name, String description) throws IOException {
String cn = classname == null ? "" : classname.trim().toLowerCase(java.util.Locale.ROOT);
if (cn.isEmpty()) return false;
String safeName = FurnitureTextProvider.sanitize(name);
String safeDesc = FurnitureTextProvider.sanitize(description);
Path target = locateFile(cn);
if (target == null) return false;
String raw = Files.readString(target, StandardCharsets.UTF_8);
String edited = replaceEntryFields(raw, cn, safeName, safeDesc);
if (edited == null || edited.equals(raw)) {
// classname not present in this file, or no change
return edited != null && !edited.equals(raw);
}
backup(target);
atomicWrite(target, edited);
return true;
}
/** For single-file just returns the file; for split-tier, the tier file that contains cn. */
private Path locateFile(String cn) throws IOException {
if (!directory) {
// confirm existence via the reader (size-guarded, parses the same way)
return containsClassname(source, cn) ? source : null;
}
// split-tier: iterate tiers in OVERRIDE order (later tiers win); pick the last containing cn
Path winner = null;
for (Path tierFile : splitTierFilesInOrder()) {
if (containsClassname(tierFile, cn)) winner = tierFile;
}
return winner;
}
private boolean containsClassname(Path file, String cn) {
for (FurnidataEntry e : new FurnidataReader(file, maxBytes).read()) {
if (e.classname() != null && e.classname().trim().toLowerCase(java.util.Locale.ROOT).equals(cn)) return true;
}
return false;
}
/**
* Replace the "name" and "description" string values inside the JSON object that holds
* "classname": "<cn>". Preserves everything else (comments, ordering, formatting).
* Handles double- and single-quoted JSON5 keys/values. Returns null if cn not found.
*/
static String replaceEntryFields(String raw, String cn, String name, String description) {
// find the classname value occurrence (case-insensitive on the value)
Pattern classProp = Pattern.compile(
"([\"'])classname\\1\\s*:\\s*([\"'])((?:\\\\.|(?!\\2).)*)\\2", Pattern.CASE_INSENSITIVE);
Matcher m = classProp.matcher(raw);
int objStart = -1, objEnd = -1;
while (m.find()) {
String val = m.group(3).trim().toLowerCase(java.util.Locale.ROOT);
if (!val.equals(cn)) continue;
// expand to the enclosing { ... }
objStart = lastUnbalancedBrace(raw, m.start());
objEnd = matchingClose(raw, objStart);
break;
}
if (objStart < 0 || objEnd < 0) return null;
String obj = raw.substring(objStart, objEnd + 1);
String newObj = replaceField(obj, "name", name);
newObj = replaceField(newObj, "description", description);
return raw.substring(0, objStart) + newObj + raw.substring(objEnd + 1);
}
private static String replaceField(String obj, String field, String value) {
Pattern p = Pattern.compile(
"(([\"'])" + Pattern.quote(field) + "\\2\\s*:\\s*)([\"'])((?:\\\\.|(?!\\3).)*)\\3");
Matcher m = p.matcher(obj);
if (!m.find()) return obj; // field absent leave object as-is
String replacement = m.group(1) + '"' + jsonEscape(value) + '"';
return obj.substring(0, m.start()) + replacement + obj.substring(m.end());
}
private static int lastUnbalancedBrace(String s, int from) {
int depth = 0;
for (int i = from; i >= 0; i--) {
char c = s.charAt(i);
if (c == '}') depth++;
else if (c == '{') { if (depth == 0) return i; depth--; }
}
return -1;
}
private static int matchingClose(String s, int open) {
int depth = 0; boolean inStr = false; char q = 0;
for (int i = open; i < s.length(); i++) {
char c = s.charAt(i);
if (inStr) { if (c == '\\') { i++; } else if (c == q) inStr = false; continue; }
if (c == '"' || c == '\'') { inStr = true; q = c; }
else if (c == '{') depth++;
else if (c == '}') { depth--; if (depth == 0) return i; }
}
return -1;
}
private static String jsonEscape(String v) {
StringBuilder b = new StringBuilder(v.length() + 8);
for (int i = 0; i < v.length(); i++) {
char c = v.charAt(i);
if (c == '"' || c == '\\') b.append('\\').append(c);
else b.append(c);
}
return b.toString();
}
/**
* Enumerate every data file reachable from the split-tier base directory, in
* override order (core custom seasonal, or the order declared in the top-level
* {@code manifest.json(5)}). Within each tier the per-tier manifest's {@code files}
* array determines the file order.
*
* <p>All resolved paths are checked against the normalised base directory via
* {@link #safeResolve}: any entry that would escape the base is silently skipped.
*
* @return ordered list of existing, in-bounds data files (earliest tier first).
*/
private List<Path> splitTierFilesInOrder() throws IOException {
Path base = source.toAbsolutePath().normalize();
List<String> tiers = manifestList(base, "tiers", DEFAULT_TIERS);
List<Path> result = new ArrayList<>();
for (String tier : tiers) {
Path tierDir = safeResolve(base, tier);
if (tierDir == null || !Files.isDirectory(tierDir)) continue;
for (String fileName : manifestList(tierDir, "files", List.of())) {
Path file = safeResolve(base, tierDir.resolve(fileName).toString());
if (file == null || !Files.isRegularFile(file)) continue;
result.add(file);
}
}
return result;
}
/**
* Resolve {@code entry} relative to {@code base} and verify the result stays
* inside {@code base} (path-traversal guard).
*
* @param base the normalised absolute base directory.
* @param entry a path string (may be relative or absolute, may contain {@code ..}).
* @return the normalised absolute path if it is inside {@code base}; {@code null} otherwise.
*/
private static Path safeResolve(Path base, String entry) {
try {
Path resolved = base.resolve(entry).toAbsolutePath().normalize();
return resolved.startsWith(base) ? resolved : null;
} catch (Exception e) {
return null;
}
}
/**
* Read the {@code key} string-array from the first manifest file found in {@code dir}
* ({@code manifest.json5} then {@code manifest.json}). Falls back to {@code fallback}
* if no manifest exists or the key is absent/empty.
*/
private List<String> manifestList(Path dir, String key, List<String> fallback) {
for (String name : MANIFEST_NAMES) {
Path m = dir.resolve(name);
if (!Files.exists(m)) continue;
try {
String stripped = FurnidataReader.stripJson5(
Files.readString(m, StandardCharsets.UTF_8));
com.google.gson.JsonObject obj =
com.google.gson.JsonParser.parseString(stripped).getAsJsonObject();
if (obj.has(key) && obj.get(key).isJsonArray()) {
List<String> list = new ArrayList<>();
for (com.google.gson.JsonElement el : obj.getAsJsonArray(key))
list.add(el.getAsString());
if (!list.isEmpty()) return list;
}
} catch (Exception ignored) {
// bad manifest fall through to next candidate / fallback
}
}
return fallback;
}
private void backup(Path target) throws IOException {
Path bak = target.resolveSibling(target.getFileName() + ".bak." + System.nanoTime());
Files.copy(target, bak, StandardCopyOption.COPY_ATTRIBUTES);
pruneBackups(target);
}
private void pruneBackups(Path target) throws IOException {
String prefix = target.getFileName() + ".bak.";
try (var stream = Files.list(target.getParent())) {
List<Path> baks = stream.filter(p -> p.getFileName().toString().startsWith(prefix))
.sorted(Comparator.comparingLong(p -> backupStamp(p))).toList();
for (int i = 0; i < baks.size() - backupKeep; i++) Files.deleteIfExists(baks.get(i));
}
}
private static long backupStamp(Path p) {
String s = p.getFileName().toString();
try { return Long.parseLong(s.substring(s.lastIndexOf('.') + 1)); } catch (Exception e) { return 0L; }
}
private void atomicWrite(Path target, String content) throws IOException {
Path tmp = target.resolveSibling(target.getFileName() + ".tmp." + System.nanoTime());
Files.writeString(tmp, content, StandardCharsets.UTF_8);
try {
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
}
}
/** Restore the most recent backup of the (single-file) target. @return true if restored. */
public boolean revertLastBackup() throws IOException {
if (directory) return revertSplitTier();
return revertFile(source);
}
private boolean revertFile(Path target) throws IOException {
String prefix = target.getFileName() + ".bak.";
try (var stream = Files.list(target.getParent())) {
Path latest = stream.filter(p -> p.getFileName().toString().startsWith(prefix))
.max(Comparator.comparingLong(FurnidataWriter::backupStamp)).orElse(null);
if (latest == null) return false;
atomicWrite(target, Files.readString(latest, StandardCharsets.UTF_8));
return true;
}
}
private boolean revertSplitTier() throws IOException {
boolean any = false;
for (Path f : splitTierFilesInOrder()) any |= revertFile(f);
return any;
}
}
@@ -0,0 +1,181 @@
package com.eu.habbo.habbohotel.items;
import com.eu.habbo.Emulator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* In-memory index of furnidata display names, keyed by the lowercased base
* classname (the {@code *N} colour-variant suffix is stripped). Read lazily by
* {@link Item#getDisplayName()}. Names are sanitized at index time.
*
* Thread-safety: the index is held behind a {@code volatile} reference; readers
* never block; {@link #reindex(List)} builds a fresh map and swaps it atomically.
*/
public class FurnitureTextProvider {
private static final int MAX_LEN = 256;
private static final Logger LOGGER = LoggerFactory.getLogger(FurnitureTextProvider.class);
private static final long DEFAULT_MAX_BYTES = 64L * 1024 * 1024;
private final boolean enabled;
private volatile Map<String, FurniText> index = Map.of();
private volatile Path source;
private FurnidataWatcher watcher;
public FurnitureTextProvider(boolean enabled) {
this.enabled = enabled;
}
/** Production constructor: reads the enable toggle from config. */
public FurnitureTextProvider() {
this(Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.names.enabled", "true")));
}
/** Resolve the furnidata source from config and build the initial index. Never throws. */
public void init() {
try {
this.source = resolveSource();
if (this.source == null) {
LOGGER.warn("FurnitureTextProvider: no furnidata source resolved - names fall back to public_name");
return;
}
reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read());
LOGGER.info("FurnitureTextProvider: indexed {} furnidata names from {}", this.index.size(), this.source);
if (Boolean.parseBoolean(Emulator.getConfig().getValue("items.furnidata.watch.enabled", "true"))) {
if (this.watcher != null) this.watcher.stop();
this.watcher = new FurnidataWatcher(this, this.source, DEFAULT_MAX_BYTES);
this.watcher.start();
}
} catch (Exception e) {
LOGGER.warn("FurnitureTextProvider.init failed — names fall back to public_name", e);
}
}
public Path getSource() {
return this.source;
}
/** Returns {@code true} when the resolved source is a directory (split-tier layout). */
public boolean isSourceDirectory() {
return this.source != null && Files.isDirectory(this.source);
}
/** Returns the byte cap used when reading furnidata files. */
public long getMaxBytes() {
return Long.parseLong(com.eu.habbo.Emulator.getConfig().getValue("items.furnidata.max.bytes", String.valueOf(DEFAULT_MAX_BYTES)));
}
/**
* Re-reads the furnidata from the current source and reindexes atomically.
* Returns the delta list (new/changed entries) from {@link #reindex(List)}.
* Never throws returns an empty list when the source is unavailable.
*/
public java.util.List<FurnidataEntry> reindexFromSource() {
try {
if (this.source == null) return java.util.List.of();
return reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read());
} catch (Exception e) {
LOGGER.warn("FurnitureTextProvider.reindexFromSource failed", e);
return java.util.List.of();
}
}
private static Path resolveSource() {
FurnidataSourceResolver.Source source = FurnidataSourceResolver.resolve();
if (source.ok()) return source.path();
LOGGER.warn("FurnitureTextProvider: no furnidata source resolved ({}) - {}", source.status(), source.message());
return null;
}
/**
* Build a fresh sanitized index, swap it in atomically, and return the
* changed/added entries (sanitized) as the delta versus the previous index.
*/
public java.util.List<FurnidataEntry> reindex(java.util.List<FurnidataEntry> entries) {
Map<String, FurniText> next = new HashMap<>(Math.max(16, entries.size() * 2));
for (FurnidataEntry e : entries) {
String key = baseKey(e.classname());
if (key == null) continue;
next.put(key, new FurniText(e.id(), e.type(), sanitize(e.name()), sanitize(e.description())));
}
Map<String, FurniText> prev = this.index;
java.util.List<FurnidataEntry> delta = new java.util.ArrayList<>();
for (Map.Entry<String, FurniText> en : next.entrySet()) {
FurniText cur = en.getValue();
FurniText old = prev.get(en.getKey());
if (old == null || !old.name().equals(cur.name()) || !old.description().equals(cur.description())) {
delta.add(new FurnidataEntry(cur.id(), en.getKey(), cur.type(), cur.name(), cur.description()));
}
}
this.index = next; // atomic reference swap
return delta;
}
/** Returns the sanitized display name for a DB classname, or null if absent/disabled. */
public String getName(String classname) {
if (!this.enabled) return null;
String key = baseKey(classname);
if (key == null) return null;
FurniText t = this.index.get(key);
return (t != null) ? t.name() : null;
}
private static String baseKey(String classname) {
if (classname == null) return null;
int star = classname.indexOf('*');
String base = (star >= 0) ? classname.substring(0, star) : classname;
base = base.trim().toLowerCase(Locale.ROOT);
return base.isEmpty() ? null : base;
}
/**
* Cap length, strip control chars/newlines, neutralize % (placeholder-injection safe).
* The 256 cap is in Java {@code char} units (UTF-16 code units), which is acceptable for
* furni names (controlled, predominantly ASCII source). Lone/astral surrogates are not
* specially handled.
*/
public static String sanitize(String value) {
if (value == null) return "";
StringBuilder sb = new StringBuilder(Math.min(value.length(), MAX_LEN));
for (int i = 0; i < value.length() && sb.length() < MAX_LEN; i++) {
char c = value.charAt(i);
if (c == '%') { sb.append(''); continue; } // fullwidth percent not a placeholder token
if (c == '\n' || c == '\r' || Character.isISOControl(c)) continue;
sb.append(c);
}
return sb.toString();
}
/**
* Returns all lowercased base classnames whose furnidata display name contains
* {@code query} (case-insensitive, substring). Results are capped at 200 to
* bound SQL IN-clause size. Returns an empty list when query is null/blank.
*/
public java.util.List<String> findClassnamesByName(String query) {
java.util.List<String> out = new java.util.ArrayList<>();
if (query == null) return out;
String q = query.trim().toLowerCase(Locale.ROOT);
if (q.isEmpty()) return out;
Map<String, FurniText> idx = this.index; // local ref (volatile)
for (Map.Entry<String, FurniText> e : idx.entrySet()) {
FurniText t = e.getValue();
if (t != null && t.name() != null && t.name().toLowerCase(Locale.ROOT).contains(q)) {
out.add(e.getKey()); // key is the lowercased base classname
if (out.size() >= 200) break; // bound IN-clause size
}
}
return out;
}
private record FurniText(int id, FurnitureType type, String name, String description) {}
}
@@ -48,6 +48,12 @@ public class Item implements ISerialize {
return item.getName().toLowerCase().startsWith("a0 pet");
}
public static boolean isBot(Item item) {
if (item == null) return false;
String name = item.getName();
return name != null && (name.startsWith("bot_") || name.startsWith("rentable_bot_"));
}
public static double getCurrentHeight(HabboItem item) {
if (item instanceof InteractionMultiHeight && item.getBaseItem().getMultiHeights().length > 0) {
if (item.getExtradata().isEmpty()) {
@@ -117,7 +123,7 @@ public class Item implements ISerialize {
if (!set.getString("vending_ids").isEmpty()) {
this.vendingItems = new TIntArrayList();
String[] vendingIds = set.getString("vending_ids").replace(";", ",").split(",");
String[] vendingIds = set.getString("vending_ids").replace(";", ",").replace(".", ",").split(",");
for (String s : vendingIds) {
this.vendingItems.add(Integer.parseInt(s.replace(" ", "")));
}
@@ -161,6 +167,20 @@ public class Item implements ISerialize {
return this.fullName;
}
/**
* Display name for user-facing/log output, sourced from furnidata (by classname).
* Falls back to the DB public_name when furnidata has no entry or names are disabled.
* Never returns null.
*/
public String getDisplayName() {
FurnitureTextProvider provider = (Emulator.getGameEnvironment() != null)
? Emulator.getGameEnvironment().getFurnitureTextProvider()
: null;
String name = (provider != null) ? provider.getName(this.name) : null;
if (name != null && !name.isBlank()) return name;
return (this.fullName != null) ? this.fullName : "";
}
public FurnitureType getType() {
return this.type;
}
@@ -13,11 +13,13 @@ import org.slf4j.LoggerFactory;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.concurrent.atomic.AtomicBoolean;
public class InteractionGift extends HabboItem {
private static final Logger LOGGER = LoggerFactory.getLogger(InteractionGift.class);
public boolean explode = false;
private final AtomicBoolean opening = new AtomicBoolean(false);
private int[] itemId;
private int colorId = 0;
private int ribbonId = 0;
@@ -46,6 +48,15 @@ public class InteractionGift extends HabboItem {
}
}
/**
* Claims the right to open this gift, returning true exactly once. Guards
* against two near-simultaneous OpenRecycleBox packets both scheduling an
* (async, delayed) OpenGift before the wrapper is removed from the room.
*/
public boolean tryStartOpening() {
return this.opening.compareAndSet(false, true);
}
@Override
public void serializeExtradata(ServerMessage serverMessage) {
//serverMessage.appendInt(this.colorId * 1000 + this.ribbonId);
@@ -65,9 +65,8 @@ public class InteractionMultiHeight extends HabboItem {
if (this.getBaseItem().getMultiHeights().length > 0) {
this.setExtradata("" + (Integer.parseInt(this.getExtradata()) + 1) % (this.getBaseItem().getMultiHeights().length));
this.needsUpdate(true);
room.updateTiles(room.getLayout().getTilesAt(room.getLayout().getTile(this.getX(), this.getY()), this.getBaseItem().getWidth(), this.getBaseItem().getLength(), this.getRotation()));
room.updateItemState(this);
//room.sendComposer(new UpdateStackHeightComposer(this.getX(), this.getY(), this.getBaseItem().getMultiHeights()[Integer.valueOf(this.getExtradata())] * 256.0D).compose());
room.updateItem(this);
this.updateUnitsOnItem(room);
}
}
}
@@ -89,7 +89,9 @@ public class InteractionOneWayGate extends HabboItem {
Emulator.getThreading().run(new RoomUnitWalkToLocation(unit, tile, room, onFail, onFail));
Emulator.getThreading().run(() -> {
WiredManager.triggerUserWalksOn(room, unit, this);
if (room.isLoaded()) {
WiredManager.triggerUserWalksOn(room, unit, this);
}
}, 500);
});
@@ -29,6 +29,10 @@ public class InteractionRoomAds extends InteractionCustomValues {
{
this.put("offsetZ", "0");
}
{
this.put("scale", "100");
}
};
public InteractionRoomAds(ResultSet set, Item baseItem) throws SQLException {
@@ -18,6 +18,7 @@ import java.sql.SQLException;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Base abstract class for all wired furniture items (triggers, effects, conditions, extras).
@@ -61,7 +62,11 @@ public abstract class InteractionWired extends InteractionDefault {
*/
private static final long CACHE_EXPIRY_MS = 5 * 60 * 1000;
private long cooldown;
private volatile long cooldown;
// Ensures one box is processed by a single thread at a time, so the
// cooldown check-and-set in WiredHandler can't double-fire when a packet
// thread and the room cycle thread trigger the same box concurrently.
private final AtomicBoolean processing = new AtomicBoolean(false);
private final ConcurrentHashMap<Long, Long> userExecutionCache = new ConcurrentHashMap<>();
InteractionWired(ResultSet set, Item baseItem) throws SQLException {
@@ -93,23 +98,24 @@ public abstract class InteractionWired extends InteractionDefault {
@Override
public void run() {
if (this.needsUpdate()) {
String wiredData = this.getWiredData();
String wiredDataRaw = this.getWiredData();
final String wiredData = (wiredDataRaw == null) ? "" : wiredDataRaw;
final int currentRoomId = this.getRoomId();
final int currentId = this.getId();
if (wiredData == null) {
wiredData = "";
}
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("UPDATE items SET wired_data = ? WHERE id = ?")) {
if (this.getRoomId() != 0) {
statement.setString(1, wiredData);
} else {
statement.setString(1, "");
Emulator.getThreading().run(() -> {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("UPDATE items SET wired_data = ? WHERE id = ?")) {
if (currentRoomId != 0) {
statement.setString(1, wiredData);
} else {
statement.setString(1, "");
}
statement.setInt(2, currentId);
statement.execute();
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
statement.setInt(2, this.getId());
statement.execute();
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
});
}
super.run();
}
@@ -148,6 +154,15 @@ public abstract class InteractionWired extends InteractionDefault {
this.cooldown = newMillis;
}
/** Claims exclusive processing of this box; returns false if another thread is already in it. */
public boolean tryBeginProcessing() {
return this.processing.compareAndSet(false, true);
}
public void endProcessing() {
this.processing.set(false);
}
@Override
public boolean allowWiredResetState() {
return false;
@@ -216,6 +231,9 @@ public abstract class InteractionWired extends InteractionDefault {
public static WiredSettings readSettings(ClientMessage packet, boolean isEffect)
{
int intParamCount = packet.readInt();
if (intParamCount < 0 || intParamCount > 100) {
throw new IllegalArgumentException("Invalid intParamCount: " + intParamCount);
}
int[] intParams = new int[intParamCount];
for(int i = 0; i < intParamCount; i++)
@@ -226,6 +244,10 @@ public abstract class InteractionWired extends InteractionDefault {
String stringParam = packet.readString();
int itemCount = packet.readInt();
int selectionLimit = Emulator.getConfig() != null ? Emulator.getConfig().getInt("hotel.wired.furni.selection.count", 5) : 5;
if (itemCount < 0 || itemCount > selectionLimit * 20) {
throw new IllegalArgumentException("Invalid itemCount: " + itemCount + " exceeds maximum allowed limit");
}
int[] itemIds = new int[itemCount];
for(int i = 0; i < itemCount; i++)
@@ -154,6 +154,7 @@ public class InteractionGameTimer extends HabboItem {
@Override
public void onPickUp(Room room) {
this.endGame(room);
this.threadActive = false;
this.timeNow = this.getInitialTimeValue();
this.setExtradata(this.timeNow + "\t" + this.baseTime);
@@ -220,8 +221,7 @@ public class InteractionGameTimer extends HabboItem {
room.updateItem(this);
WiredManager.triggerGameStarts(room);
if (!this.threadActive) {
this.threadActive = true;
if (this.tryActivateTimerThread()) {
this.scheduleTimerRunnable(this.getTimerStartDelayMs());
}
} else if (client != null) {
@@ -243,8 +243,7 @@ public class InteractionGameTimer extends HabboItem {
} else {
this.unpause(room);
if (!this.threadActive) {
this.threadActive = true;
if (this.tryActivateTimerThread()) {
this.scheduleTimerRunnable(this.getTimerResumeDelayMs());
}
}
@@ -257,8 +256,7 @@ public class InteractionGameTimer extends HabboItem {
this.createNewGame(room);
WiredManager.triggerGameStarts(room);
if (!this.threadActive) {
this.threadActive = true;
if (this.tryActivateTimerThread()) {
this.scheduleTimerRunnable(this.getTimerStartDelayMs());
}
}
@@ -297,8 +295,7 @@ public class InteractionGameTimer extends HabboItem {
}
this.createNewGame(room);
WiredManager.triggerGameStarts(room);
if (!threadActive) {
threadActive = true;
if (this.tryActivateTimerThread()) {
this.scheduleTimerRunnable(this.getTimerStartDelayMs());
}
}
@@ -321,8 +318,7 @@ public class InteractionGameTimer extends HabboItem {
this.isPaused = false;
this.unpause(room);
if (!this.threadActive) {
this.threadActive = true;
if (this.tryActivateTimerThread()) {
this.scheduleTimerRunnable(this.getTimerResumeDelayMs());
}
}
@@ -406,7 +402,9 @@ public class InteractionGameTimer extends HabboItem {
}
public void setThreadActive(boolean threadActive) {
this.threadActive = threadActive;
synchronized (this) {
this.threadActive = threadActive;
}
}
public boolean isPaused() {
@@ -428,4 +426,15 @@ public class InteractionGameTimer extends HabboItem {
public int getBaseTime() {
return this.baseTime;
}
public boolean tryActivateTimerThread() {
synchronized (this) {
if (this.threadActive) {
return false;
}
this.threadActive = true;
return true;
}
}
}
@@ -190,6 +190,14 @@ public class InteractionPetBreedingNest extends HabboItem {
}
public void breed(Habbo habbo, String name, int petOneId, int petTwoId) {
// Guard before the destructive delete below: a crafted packet can call
// this on a nest that isn't full, which would delete the nest furni and
// then NPE on petOne/petTwo in the async runnable (losing the furni).
if (habbo == null || this.petOne == null || this.petTwo == null
|| habbo.getHabboInfo().getCurrentRoom() == null) {
return;
}
Emulator.getThreading().run(new QueryDeleteHabboItem(this.getId()));
this.setExtradata("2");
@@ -65,8 +65,14 @@ public class WiredConditionHabboCount extends InteractionWiredCondition {
} else {
String[] data = wiredData.split(":");
this.lowerLimit = Integer.parseInt(data[0]);
this.upperLimit = Integer.parseInt(data[1]);
if (data.length >= 2) {
try {
this.lowerLimit = Integer.parseInt(data[0].trim());
this.upperLimit = Integer.parseInt(data[1].trim());
} catch (NumberFormatException ignored) {
// malformed legacy data keep the constructed defaults
}
}
this.userSource = WiredSourceUtil.SOURCE_TRIGGER;
}
}
@@ -263,22 +263,29 @@ public class WiredConditionMatchStatePosition extends InteractionWiredCondition
} else {
String[] data = wiredData.split(":");
int itemCount = Integer.parseInt(data[0]);
if (data.length >= 5) {
try {
int itemCount = Integer.parseInt(data[0]);
String[] items = data[1].split(";");
String[] items = data[1].split(";");
for (int i = 0; i < itemCount; i++) {
String[] stuff = items[i].split("-");
for (int i = 0; i < itemCount && i < items.length; i++) {
String[] stuff = items[i].split("-");
if (stuff.length >= 6)
this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4]), Double.parseDouble(stuff[5])));
else if (stuff.length >= 5)
this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4])));
if (stuff.length >= 6)
this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4]), Double.parseDouble(stuff[5])));
else if (stuff.length >= 5)
this.settings.add(new WiredMatchFurniSetting(Integer.parseInt(stuff[0]), stuff[1], Integer.parseInt(stuff[2]), Integer.parseInt(stuff[3]), Integer.parseInt(stuff[4])));
}
this.state = data[2].equals("1");
this.direction = data[3].equals("1");
this.position = data[4].equals("1");
} catch (NumberFormatException ignored) {
// malformed legacy data keep whatever was parsed plus defaults
}
}
this.state = data[2].equals("1");
this.direction = data[3].equals("1");
this.position = data[4].equals("1");
this.altitude = false;
this.furniSource = this.settings.isEmpty() ? WiredSourceUtil.SOURCE_TRIGGER : WiredSourceUtil.SOURCE_SELECTED;
this.quantifier = QUANTIFIER_ALL;
@@ -64,8 +64,14 @@ public class WiredConditionNotHabboCount extends InteractionWiredCondition {
this.userSource = data.userSource;
} else {
String[] data = wiredData.split(":");
this.lowerLimit = Integer.parseInt(data[0]);
this.upperLimit = Integer.parseInt(data[1]);
if (data.length >= 2) {
try {
this.lowerLimit = Integer.parseInt(data[0].trim());
this.upperLimit = Integer.parseInt(data[1].trim());
} catch (NumberFormatException ignored) {
// malformed legacy data keep the constructed defaults
}
}
this.userSource = WiredSourceUtil.SOURCE_TRIGGER;
}
}
@@ -20,12 +20,13 @@ import com.eu.habbo.messages.ServerMessage;
import com.eu.habbo.messages.incoming.wired.WiredSaveException;
import com.eu.habbo.messages.outgoing.generic.alerts.UpdateFailedComposer;
import gnu.trove.procedure.TObjectProcedure;
import gnu.trove.set.hash.THashSet;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
public class WiredEffectGiveReward extends InteractionWiredEffect {
public static final int LIMIT_ONCE = 0;
@@ -37,10 +38,10 @@ public class WiredEffectGiveReward extends InteractionWiredEffect {
public int limit;
public int limitationInterval;
public int given;
public AtomicInteger given = new AtomicInteger(0);
public int rewardTime;
public boolean uniqueRewards;
public THashSet<WiredGiveRewardItem> rewardItems = new THashSet<>();
public List<WiredGiveRewardItem> rewardItems = new CopyOnWriteArrayList<>();
public int userSource = WiredSourceUtil.SOURCE_TRIGGER;
public WiredEffectGiveReward(ResultSet set, Item baseItem) throws SQLException {
@@ -71,9 +72,8 @@ public class WiredEffectGiveReward extends InteractionWiredEffect {
@Override
public String getWiredData() {
ArrayList<WiredGiveRewardItem> rewards = new ArrayList<>(this.rewardItems);
return WiredManager.getGson().toJson(new JsonData(this.limit, this.given, this.rewardTime, this.uniqueRewards, this.limitationInterval, rewards, this.getDelay(), this.userSource));
return WiredManager.getGson().toJson(new JsonData(this.limit, this.given.get(), this.rewardTime, this.uniqueRewards, this.limitationInterval, rewards, this.getDelay(), this.userSource));
}
@Override
@@ -84,7 +84,7 @@ public class WiredEffectGiveReward extends InteractionWiredEffect {
JsonData data = WiredManager.getGson().fromJson(wiredData, JsonData.class);
this.setDelay(data.delay);
this.limit = data.limit;
this.given = data.given;
this.given.set(data.given);
this.rewardTime = data.reward_time;
this.uniqueRewards = data.unique_rewards;
this.limitationInterval = data.limit_interval;
@@ -96,7 +96,7 @@ public class WiredEffectGiveReward extends InteractionWiredEffect {
String[] data = wiredData.split(":");
if (data.length > 0) {
this.limit = Integer.parseInt(data[0]);
this.given = Integer.parseInt(data[1]);
this.given.set(Integer.parseInt(data[1]));
this.rewardTime = Integer.parseInt(data[2]);
this.uniqueRewards = data[3].equals("1");
this.limitationInterval = Integer.parseInt(data[4]);
@@ -127,7 +127,7 @@ public class WiredEffectGiveReward extends InteractionWiredEffect {
public void onPickUp() {
this.limit = 0;
this.limitationInterval = 0;
this.given = 0;
this.given.set(0);
this.rewardTime = 0;
this.uniqueRewards = false;
this.rewardItems.clear();
@@ -192,7 +192,7 @@ public class WiredEffectGiveReward extends InteractionWiredEffect {
this.limit = settings.getIntParams()[2];
this.limitationInterval = settings.getIntParams()[3];
this.userSource = settings.getIntParams()[4];
this.given = 0;
this.given.set(0);
String data = settings.getStringParam();
@@ -276,15 +276,15 @@ public class WiredEffectGiveReward extends InteractionWiredEffect {
}
public int getGiven() {
return this.given;
return this.given.get();
}
public void setGiven(int given) {
this.given = given;
this.given.set(given);
}
public void incrementGiven() {
this.given++;
this.given.incrementAndGet();
}
public int getRewardTime() {
@@ -303,11 +303,11 @@ public class WiredEffectGiveReward extends InteractionWiredEffect {
this.uniqueRewards = uniqueRewards;
}
public THashSet<WiredGiveRewardItem> getRewardItems() {
public List<WiredGiveRewardItem> getRewardItems() {
return this.rewardItems;
}
public void setRewardItems(THashSet<WiredGiveRewardItem> rewardItems) {
public void setRewardItems(List<WiredGiveRewardItem> rewardItems) {
this.rewardItems = rewardItems;
}
}
@@ -190,10 +190,15 @@ public class WiredEffectMoveRotateFurni extends InteractionWiredEffect implement
}
for (String s : data[3].split("\r")) {
HabboItem item = room.getHabboItem(Integer.parseInt(s));
if (s.trim().isEmpty()) continue;
try {
HabboItem item = room.getHabboItem(Integer.parseInt(s.trim()));
if (item != null)
this.items.add(item);
if (item != null)
this.items.add(item);
} catch (NumberFormatException ignored) {
// skip malformed furni id token
}
}
}
this.furniSource = this.items.isEmpty() ? WiredSourceUtil.SOURCE_TRIGGER : WiredSourceUtil.SOURCE_SELECTED;
@@ -151,7 +151,7 @@ public class WiredEffectTeleport extends InteractionWiredEffect {
@Override
public boolean execute(InteractionWiredTrigger object) {
if (!object.isTriggeredByRoomUnit()) {
invalidTriggers.add(object.getId());
invalidTriggers.add(object.getBaseItem().getSpriteId());
}
return true;
}
@@ -252,7 +252,7 @@ public abstract class WiredEffectUserFurniBase extends InteractionWiredEffect {
@Override
public boolean execute(InteractionWiredTrigger object) {
if (!object.isTriggeredByRoomUnit()) {
invalidTriggers.add(object.getId());
invalidTriggers.add(object.getBaseItem().getSpriteId());
}
return true;
}
@@ -227,6 +227,18 @@ public final class WiredVariableReferenceSupport {
USER_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix));
}
/**
* Drops all cached shared-variable assignments belonging to a room. Both
* caches are keyed "roomId:itemId[:userId]", so the trailing colon makes the
* prefix match the exact room id. Called on room dispose so the static caches
* don't retain entries for the JVM lifetime.
*/
public static void invalidateRoom(int roomId) {
String prefix = roomId + ":";
USER_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix));
ROOM_ASSIGNMENT_CACHE.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix));
}
public static SharedRoomAssignment getSharedRoomAssignment(WiredExtraVariableReference reference) {
if (reference == null || !reference.isRoomReference()) {
return null;
@@ -384,61 +396,69 @@ public final class WiredVariableReferenceSupport {
}
private static void upsertSharedUserAssignment(int sourceRoomId, int sourceVariableItemId, int userId, SharedUserAssignment assignment) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("INSERT INTO room_user_wired_variables (room_id, user_id, variable_item_id, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = VALUES(updated_at)")) {
statement.setInt(1, sourceRoomId);
statement.setInt(2, userId);
statement.setInt(3, sourceVariableItemId);
Emulator.getThreading().run(() -> {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("INSERT INTO room_user_wired_variables (room_id, user_id, variable_item_id, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = VALUES(updated_at)")) {
statement.setInt(1, sourceRoomId);
statement.setInt(2, userId);
statement.setInt(3, sourceVariableItemId);
if (assignment.getValue() == null) {
statement.setNull(4, java.sql.Types.INTEGER);
} else {
statement.setInt(4, assignment.getValue());
if (assignment.getValue() == null) {
statement.setNull(4, java.sql.Types.INTEGER);
} else {
statement.setInt(4, assignment.getValue());
}
statement.setInt(5, assignment.getCreatedAt());
statement.setInt(6, assignment.getUpdatedAt());
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Failed to store shared wired user variable {} for room {} user {}", sourceVariableItemId, sourceRoomId, userId, e);
}
statement.setInt(5, assignment.getCreatedAt());
statement.setInt(6, assignment.getUpdatedAt());
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Failed to store shared wired user variable {} for room {} user {}", sourceVariableItemId, sourceRoomId, userId, e);
}
});
}
private static void deleteSharedUserAssignment(int sourceRoomId, int sourceVariableItemId, int userId) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("DELETE FROM room_user_wired_variables WHERE room_id = ? AND user_id = ? AND variable_item_id = ?")) {
statement.setInt(1, sourceRoomId);
statement.setInt(2, userId);
statement.setInt(3, sourceVariableItemId);
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Failed to delete shared wired user variable {} for room {} user {}", sourceVariableItemId, sourceRoomId, userId, e);
}
Emulator.getThreading().run(() -> {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("DELETE FROM room_user_wired_variables WHERE room_id = ? AND user_id = ? AND variable_item_id = ?")) {
statement.setInt(1, sourceRoomId);
statement.setInt(2, userId);
statement.setInt(3, sourceVariableItemId);
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Failed to delete shared wired user variable {} for room {} user {}", sourceVariableItemId, sourceRoomId, userId, e);
}
});
}
private static void upsertSharedRoomAssignment(int sourceRoomId, int sourceVariableItemId, SharedRoomAssignment assignment) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("INSERT INTO room_wired_variables (room_id, variable_item_id, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = VALUES(updated_at)")) {
statement.setInt(1, sourceRoomId);
statement.setInt(2, sourceVariableItemId);
statement.setInt(3, assignment.getValue());
statement.setInt(4, 0);
statement.setInt(5, assignment.getUpdatedAt());
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Failed to store shared wired room variable {} for room {}", sourceVariableItemId, sourceRoomId, e);
}
Emulator.getThreading().run(() -> {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("INSERT INTO room_wired_variables (room_id, variable_item_id, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value), updated_at = VALUES(updated_at)")) {
statement.setInt(1, sourceRoomId);
statement.setInt(2, sourceVariableItemId);
statement.setInt(3, assignment.getValue());
statement.setInt(4, 0);
statement.setInt(5, assignment.getUpdatedAt());
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Failed to store shared wired room variable {} for room {}", sourceVariableItemId, sourceRoomId, e);
}
});
}
private static void deleteSharedRoomAssignment(int sourceRoomId, int sourceVariableItemId) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("DELETE FROM room_wired_variables WHERE room_id = ? AND variable_item_id = ?")) {
statement.setInt(1, sourceRoomId);
statement.setInt(2, sourceVariableItemId);
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Failed to delete shared wired room variable {} for room {}", sourceVariableItemId, sourceRoomId, e);
}
Emulator.getThreading().run(() -> {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("DELETE FROM room_wired_variables WHERE room_id = ? AND variable_item_id = ?")) {
statement.setInt(1, sourceRoomId);
statement.setInt(2, sourceVariableItemId);
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Failed to delete shared wired room variable {} for room {}", sourceVariableItemId, sourceRoomId, e);
}
});
}
private static String createDefinitionPrefix(int sourceRoomId, int sourceVariableItemId) {
@@ -123,7 +123,11 @@ public class WiredTriggerRepeaterLong extends InteractionWiredTrigger implements
@Override
public boolean saveData(WiredSettings settings) {
if (settings.getIntParams().length < 1) return false;
this.repeatTime = settings.getIntParams()[0] * 5000;
int interval = settings.getIntParams()[0];
if (interval < 1) {
interval = 1;
}
this.repeatTime = interval * 5000;
// No accumulated time reset needed - using global tick count
return true;
}
@@ -0,0 +1,106 @@
package com.eu.habbo.habbohotel.mentions;
import com.eu.habbo.habbohotel.rooms.Room;
import com.eu.habbo.habbohotel.users.Habbo;
import java.sql.ResultSet;
import java.sql.SQLException;
public class HabboMention {
public static final int TYPE_DIRECT = 0;
public static final int TYPE_ROOM = 1;
private final int id;
private final int targetUserId;
private final int senderUserId;
private final String senderUsername;
private final int roomId;
private final String roomName;
private final String message;
private final int mentionType;
private final int timestamp;
private final boolean read;
private final String senderFigure;
public HabboMention(ResultSet set) throws SQLException {
this.id = set.getInt("id");
this.targetUserId = set.getInt("target_user_id");
this.senderUserId = set.getInt("sender_user_id");
this.senderUsername = set.getString("sender_username");
this.roomId = set.getInt("room_id");
this.roomName = set.getString("room_name");
this.message = set.getString("message");
this.mentionType = set.getInt("mention_type");
this.timestamp = set.getInt("timestamp");
this.read = set.getInt("read") == 1;
this.senderFigure = hasSenderFigure(set) ? set.getString("sender_figure") : "";
}
private static boolean hasSenderFigure(ResultSet set) {
try {
set.findColumn("sender_figure");
return true;
} catch (SQLException e) {
return false;
}
}
public HabboMention(int targetUserId, int id, Habbo sender, Room room, String roomName, String message, int mentionType, int timestamp) {
this.id = id;
this.targetUserId = targetUserId;
this.senderUserId = sender.getHabboInfo().getId();
this.senderUsername = sender.getHabboInfo().getUsername();
this.roomId = room.getId();
this.roomName = roomName;
this.message = message;
this.mentionType = mentionType;
this.timestamp = timestamp;
this.read = false;
this.senderFigure = sender.getHabboInfo().getLook();
}
public int getId() {
return this.id;
}
public int getTargetUserId() {
return this.targetUserId;
}
public int getSenderUserId() {
return this.senderUserId;
}
public String getSenderUsername() {
return this.senderUsername;
}
public int getRoomId() {
return this.roomId;
}
public String getRoomName() {
return this.roomName;
}
public String getMessage() {
return this.message;
}
public int getMentionType() {
return this.mentionType;
}
public int getTimestamp() {
return this.timestamp;
}
public boolean isRead() {
return this.read;
}
public String getSenderFigure() {
return this.senderFigure == null ? "" : this.senderFigure;
}
}
@@ -0,0 +1,463 @@
package com.eu.habbo.habbohotel.mentions;
import com.eu.habbo.Emulator;
import com.eu.habbo.habbohotel.messenger.MessengerBuddy;
import com.eu.habbo.habbohotel.rooms.Room;
import com.eu.habbo.habbohotel.rooms.RoomChatType;
import com.eu.habbo.habbohotel.users.Habbo;
import com.eu.habbo.habbohotel.users.HabboManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class MentionManager {
private static final Logger LOGGER = LoggerFactory.getLogger(MentionManager.class);
private static final int ROOM_NAME_MAX_LENGTH = 64;
private static final int MESSAGE_MAX_LENGTH = 255;
private final ConcurrentHashMap<Integer, Long> cooldowns = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Integer, Long> roomBroadcastCooldowns = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Integer, Long> requestListCooldowns = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Integer, Long> markReadCooldowns = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Integer, Long> markAllCooldowns = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Integer, Long> deleteCooldowns = new ConcurrentHashMap<>();
private volatile long lastPrune = System.currentTimeMillis();
private static final long PRUNE_INTERVAL_MS = 5 * 60_000L;
public boolean isEnabled() {
return Emulator.getConfig().getInt("mentions.enabled", 1) == 1;
}
public enum BroadcastScope {
NONE,
ROOM,
FRIENDS,
EVERYONE
}
public static final String PERMISSION_EVERYONE = "acc_mention_everyone";
public static final String PERMISSION_FRIENDS = "acc_mention_friends";
private Set<String> parseAliases(String configKey, String defaultValue) {
Set<String> aliases = new HashSet<>();
String raw = Emulator.getConfig().getValue(configKey, defaultValue);
for (String alias : raw.split(",")) {
String trimmed = alias.trim().toLowerCase();
if (!trimmed.isEmpty()) {
aliases.add(trimmed);
}
}
return aliases;
}
private Set<String> roomAliases() {
return parseAliases("mentions.room.aliases", "room,stanza");
}
private Set<String> friendsAliases() {
return parseAliases("mentions.friends.aliases", "friends,amici");
}
private Set<String> everyoneAliases() {
return parseAliases("mentions.everyone.aliases", "all,everyone,tutti");
}
private BroadcastScope classifyAlias(String alias,
Set<String> everyone,
Set<String> friends,
Set<String> room) {
if (alias.isEmpty()) return BroadcastScope.NONE;
if (everyone.contains(alias)) return BroadcastScope.EVERYONE;
if (friends.contains(alias)) return BroadcastScope.FRIENDS;
if (room.contains(alias)) return BroadcastScope.ROOM;
return BroadcastScope.NONE;
}
public void process(Habbo sender, Room room, String message, RoomChatType type) {
try {
if (!this.isEnabled()) {
return;
}
if (sender == null || room == null || message == null) {
return;
}
if (message.isEmpty() || message.indexOf('@') < 0) {
return;
}
int senderId = sender.getHabboInfo().getId();
long now = System.currentTimeMillis();
long cooldownMs = Emulator.getConfig().getInt("mentions.cooldown.ms", 3000);
Long last = this.cooldowns.get(senderId);
if (last != null && (now - last) < cooldownMs) {
return;
}
Set<String> roomAliases = this.roomAliases();
Set<String> friendsAliases = this.friendsAliases();
Set<String> everyoneAliases = this.everyoneAliases();
BroadcastScope broadcastScope = BroadcastScope.NONE;
LinkedHashSet<String> directTokens = new LinkedHashSet<>();
for (String token : message.split("\\s+")) {
if (token.length() < 2 || token.charAt(0) != '@') {
continue;
}
String raw = token.substring(1);
String aliasCandidate = trimTrailingPunctuation(raw).toLowerCase();
BroadcastScope scope = this.classifyAlias(aliasCandidate, everyoneAliases, friendsAliases, roomAliases);
if (scope != BroadcastScope.NONE) {
if (scope.ordinal() > broadcastScope.ordinal()) {
broadcastScope = scope;
}
} else if (!raw.isEmpty()) {
directTokens.add(raw);
}
}
if (broadcastScope == BroadcastScope.EVERYONE && !sender.hasPermission(PERMISSION_EVERYONE)) {
broadcastScope = BroadcastScope.NONE;
} else if (broadcastScope == BroadcastScope.FRIENDS && !sender.hasPermission(PERMISSION_FRIENDS)) {
broadcastScope = BroadcastScope.NONE;
}
if (broadcastScope == BroadcastScope.NONE && directTokens.isEmpty()) {
return;
}
if (broadcastScope != BroadcastScope.NONE) {
long roomCooldownMs = Emulator.getConfig().getInt("mentions.room.cooldown.ms", 15000);
Long lastRoom = this.roomBroadcastCooldowns.get(senderId);
if (lastRoom != null && (now - lastRoom) < roomCooldownMs) {
return;
}
}
int maxTargets = Emulator.getConfig().getInt("mentions.max.targets", 50);
if (maxTargets <= 0) maxTargets = 1;
int maxDirectTokens = Math.min(directTokens.size(), maxTargets);
List<Habbo> targets = new ArrayList<>();
Set<Integer> seen = new HashSet<>();
switch (broadcastScope) {
case EVERYONE:
this.collectEveryoneTargets(senderId, targets, seen, maxTargets);
break;
case FRIENDS:
this.collectFriendsTargets(sender, senderId, targets, seen, maxTargets);
break;
case ROOM:
this.collectRoomTargets(room, senderId, targets, seen, maxTargets, true);
break;
case NONE:
default:
int processed = 0;
for (String token : directTokens) {
if (processed++ >= maxDirectTokens) break;
Habbo habbo = this.resolveHabbo(room, token);
if (habbo == null || habbo.getHabboInfo().getId() == senderId) {
continue;
}
if (!acceptsMention(habbo, false)) {
continue;
}
if (seen.add(habbo.getHabboInfo().getId())) {
targets.add(habbo);
}
if (targets.size() >= maxTargets) {
break;
}
}
break;
}
if (targets.isEmpty()) {
return;
}
this.cooldowns.put(senderId, now);
if (broadcastScope != BroadcastScope.NONE) this.roomBroadcastCooldowns.put(senderId, now);
this.pruneCooldownsIfDue(now);
int mentionType = (broadcastScope != BroadcastScope.NONE) ? HabboMention.TYPE_ROOM : HabboMention.TYPE_DIRECT;
int timestamp = Emulator.getIntUnixTimestamp();
String roomName = truncate(room.getName(), ROOM_NAME_MAX_LENGTH);
String storedMessage = truncate(message, MESSAGE_MAX_LENGTH);
for (Habbo target : targets) {
this.store(target, sender, room, storedMessage, mentionType, timestamp, roomName);
}
} catch (Exception e) {
LOGGER.error("Failed to process mentions.", e);
}
}
private void collectRoomTargets(Room room, int senderId, List<Habbo> targets, Set<Integer> seen, int maxTargets, boolean isBroadcast) {
for (Habbo habbo : room.getHabbos()) {
if (habbo == null || habbo.getHabboInfo().getId() == senderId) continue;
if (!acceptsMention(habbo, isBroadcast)) continue;
if (seen.add(habbo.getHabboInfo().getId())) targets.add(habbo);
if (targets.size() >= maxTargets) break;
}
}
private void collectFriendsTargets(Habbo sender, int senderId, List<Habbo> targets, Set<Integer> seen, int maxTargets) {
if (sender.getMessenger() == null) return;
HabboManager habboManager = Emulator.getGameEnvironment().getHabboManager();
for (MessengerBuddy buddy : sender.getMessenger().getFriends().values()) {
if (buddy == null) continue;
int buddyId = buddy.getId();
if (buddyId == senderId) continue;
Habbo online = habboManager.getHabbo(buddyId);
if (online == null) continue;
if (!acceptsMention(online, true)) continue;
if (seen.add(buddyId)) targets.add(online);
if (targets.size() >= maxTargets) break;
}
}
private void collectEveryoneTargets(int senderId, List<Habbo> targets, Set<Integer> seen, int maxTargets) {
for (Habbo habbo : Emulator.getGameEnvironment().getHabboManager().getOnlineHabbos().values()) {
if (habbo == null || habbo.getHabboInfo().getId() == senderId) continue;
if (!acceptsMention(habbo, true)) continue;
if (seen.add(habbo.getHabboInfo().getId())) targets.add(habbo);
if (targets.size() >= maxTargets) break;
}
}
private boolean acceptsMention(Habbo recipient, boolean isBroadcast) {
if (recipient == null) return false;
if (recipient.getClient() == null) return false;
if (recipient.getHabboStats() == null) return false;
if (!recipient.getHabboStats().mentionsEnabled()) return false;
if (isBroadcast && !recipient.getHabboStats().massMentionsEnabled()) return false;
return true;
}
private void store(Habbo target, Habbo sender, Room room, String message, int mentionType, int timestamp, String roomName) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO habbo_mentions (target_user_id, sender_user_id, sender_username, room_id, room_name, message, mention_type, timestamp, `read`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)",
Statement.RETURN_GENERATED_KEYS)) {
statement.setInt(1, target.getHabboInfo().getId());
statement.setInt(2, sender.getHabboInfo().getId());
statement.setString(3, sender.getHabboInfo().getUsername());
statement.setInt(4, room.getId());
statement.setString(5, roomName);
statement.setString(6, message);
statement.setInt(7, mentionType);
statement.setInt(8, timestamp);
statement.executeUpdate();
int generatedId = 0;
try (ResultSet keys = statement.getGeneratedKeys()) {
if (keys.next()) {
generatedId = keys.getInt(1);
}
}
if (generatedId <= 0) {
return;
}
HabboMention mention = new HabboMention(target.getHabboInfo().getId(), generatedId, sender, room, roomName, message, mentionType, timestamp);
if (target.getClient() != null) {
target.getClient().sendResponse(new com.eu.habbo.messages.outgoing.mentions.MentionReceivedComposer(mention));
}
} catch (SQLException e) {
LOGGER.error("Failed to store mention.", e);
}
}
public List<HabboMention> getMentions(int userId, int limit) {
List<HabboMention> mentions = new ArrayList<>();
if (limit <= 0) limit = 50;
if (limit > 200) limit = 200;
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(
"SELECT habbo_mentions.*, users.look AS sender_figure FROM habbo_mentions LEFT JOIN users ON users.id = habbo_mentions.sender_user_id WHERE target_user_id = ? ORDER BY id DESC LIMIT ?")) {
statement.setInt(1, userId);
statement.setInt(2, limit);
try (ResultSet set = statement.executeQuery()) {
while (set.next()) {
mentions.add(new HabboMention(set));
}
}
} catch (SQLException e) {
LOGGER.error("Failed to load mentions.", e);
}
return mentions;
}
public void markRead(int userId, int mode, int mentionId) {
if (mode != 0 && mode != 1) return;
if (mode == 1 && mentionId <= 0) return;
String query = mode == 1
? "UPDATE habbo_mentions SET `read` = 1 WHERE target_user_id = ? AND id = ? AND `read` = 0"
: "UPDATE habbo_mentions SET `read` = 1 WHERE target_user_id = ? AND `read` = 0";
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(query)) {
statement.setInt(1, userId);
if (mode == 1) {
statement.setInt(2, mentionId);
}
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Failed to mark mentions as read.", e);
}
}
public void delete(int userId, int mentionId) {
if (mentionId <= 0) return;
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(
"DELETE FROM habbo_mentions WHERE target_user_id = ? AND id = ?")) {
statement.setInt(1, userId);
statement.setInt(2, mentionId);
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Failed to delete mention.", e);
}
}
public boolean tryAcquireRequestList(int userId) {
long cooldownMs = Emulator.getConfig().getInt("mentions.request.cooldown.ms", 2000);
return tryAcquire(this.requestListCooldowns, userId, cooldownMs);
}
public boolean tryAcquireMarkRead(int userId, int mode) {
long cooldownMs;
ConcurrentHashMap<Integer, Long> bucket;
if (mode == 1) {
cooldownMs = Emulator.getConfig().getInt("mentions.markread.cooldown.ms", 500);
bucket = this.markReadCooldowns;
} else {
cooldownMs = Emulator.getConfig().getInt("mentions.markall.cooldown.ms", 5000);
bucket = this.markAllCooldowns;
}
return tryAcquire(bucket, userId, cooldownMs);
}
public boolean tryAcquireDelete(int userId) {
long cooldownMs = Emulator.getConfig().getInt("mentions.delete.cooldown.ms", 500);
return tryAcquire(this.deleteCooldowns, userId, cooldownMs);
}
private boolean tryAcquire(ConcurrentHashMap<Integer, Long> bucket, int userId, long cooldownMs) {
long now = System.currentTimeMillis();
Long last = bucket.get(userId);
if (last != null && (now - last) < cooldownMs) {
return false;
}
bucket.put(userId, now);
this.pruneCooldownsIfDue(now);
return true;
}
private void pruneCooldownsIfDue(long now) {
if (now - this.lastPrune < PRUNE_INTERVAL_MS) return;
this.lastPrune = now;
long mentionWindow = Emulator.getConfig().getInt("mentions.cooldown.ms", 3000);
long roomWindow = Emulator.getConfig().getInt("mentions.room.cooldown.ms", 15000);
long requestWindow = Emulator.getConfig().getInt("mentions.request.cooldown.ms", 2000);
long markReadWindow = Emulator.getConfig().getInt("mentions.markread.cooldown.ms", 500);
long markAllWindow = Emulator.getConfig().getInt("mentions.markall.cooldown.ms", 5000);
long deleteWindow = Emulator.getConfig().getInt("mentions.delete.cooldown.ms", 500);
prune(this.cooldowns, now, mentionWindow);
prune(this.roomBroadcastCooldowns, now, roomWindow);
prune(this.requestListCooldowns, now, requestWindow);
prune(this.markReadCooldowns, now, markReadWindow);
prune(this.markAllCooldowns, now, markAllWindow);
prune(this.deleteCooldowns, now, deleteWindow);
}
private static void prune(ConcurrentHashMap<Integer, Long> bucket, long now, long windowMs) {
Iterator<Map.Entry<Integer, Long>> it = bucket.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<Integer, Long> entry = it.next();
Long value = entry.getValue();
if (value == null || (now - value) >= windowMs) {
it.remove();
}
}
}
private static final String TRAILING_PUNCTUATION = ".,!?;:)]}\"'";
private static String trimTrailingPunctuation(String value) {
int end = value.length();
while (end > 0 && TRAILING_PUNCTUATION.indexOf(value.charAt(end - 1)) >= 0) {
end--;
}
return value.substring(0, end);
}
private static String truncate(String value, int max) {
if (value == null) return "";
if (value.length() <= max) return value;
return value.substring(0, max);
}
private boolean isBotOrPetName(Room room, String token) {
if (room == null || token == null || token.isEmpty()) return false;
List<com.eu.habbo.habbohotel.bots.Bot> bots = room.getBots(token);
if (bots != null && !bots.isEmpty()) return true;
if (room.getUnitManager() != null && room.getUnitManager().getPets() != null) {
for (com.eu.habbo.habbohotel.pets.Pet pet : room.getUnitManager().getPets()) {
if (pet != null && pet.getName() != null && pet.getName().equalsIgnoreCase(token)) {
return true;
}
}
}
return false;
}
private Habbo resolveHabbo(Room room, String rawToken) {
if (isBotOrPetName(room, rawToken)) {
return null;
}
String trimmedForBotCheck = trimTrailingPunctuation(rawToken);
if (!trimmedForBotCheck.equals(rawToken) && isBotOrPetName(room, trimmedForBotCheck)) {
return null;
}
Habbo habbo = room.getHabbo(rawToken);
if (habbo != null) {
return habbo;
}
HabboManager habboManager = Emulator.getGameEnvironment().getHabboManager();
habbo = habboManager.getHabbo(rawToken);
if (habbo != null) {
return habbo;
}
String trimmed = trimTrailingPunctuation(rawToken);
if (!trimmed.isEmpty() && !trimmed.equals(rawToken)) {
habbo = room.getHabbo(trimmed);
if (habbo != null) {
return habbo;
}
return habboManager.getHabbo(trimmed);
}
return null;
}
}
@@ -53,7 +53,7 @@ public class Messenger {
public static THashSet<MessengerBuddy> searchUsers(String username) {
THashSet<MessengerBuddy> users = new THashSet<>();
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection(); PreparedStatement statement = connection.prepareStatement("SELECT * FROM users WHERE username LIKE ? ORDER BY username ASC LIMIT " + Emulator.getConfig().getInt("hotel.messenger.search.maxresults"))) {
statement.setString(1, username + "%");
statement.setString(1, com.eu.habbo.util.SqlLikeEscaper.escape(username) + "%");
try (ResultSet set = statement.executeQuery()) {
while (set.next()) {
users.add(new MessengerBuddy(set, false));
@@ -0,0 +1,97 @@
package com.eu.habbo.habbohotel.modtool;
import com.eu.habbo.Emulator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;
/**
* Append-only audit trail for privileged housekeeping/admin actions (rank grants,
* currency grants, etc.). There was previously no record of which operator did
* what to whom. Writes are dispatched off the calling thread; the backing table
* is created on first use so no manual migration is required.
*/
public final class HousekeepingAuditLog {
private static final Logger LOGGER = LoggerFactory.getLogger(HousekeepingAuditLog.class);
private static volatile boolean tableReady = false;
private HousekeepingAuditLog() {
}
/**
* Records a privileged action asynchronously.
*
* @param operatorId the acting staff member's user id
* @param operatorName the acting staff member's username
* @param action a short action key, e.g. {@code "user.set_rank"}
* @param targetUserId the affected user's id (0 if not applicable)
* @param detail free-form detail, e.g. {@code "rankId=6"} (capped to 512 chars)
* @param ip the operator's IP, for correlation
*/
public static void log(int operatorId, String operatorName, String action, int targetUserId, String detail, String ip) {
Emulator.getThreading().run(() -> writeEntry(operatorId, operatorName, action, targetUserId, detail, ip));
}
private static void writeEntry(int operatorId, String operatorName, String action, int targetUserId, String detail, String ip) {
ensureTable();
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO housekeeping_log (operator_id, operator_name, action, target_user_id, detail, ip, timestamp) " +
"VALUES (?, ?, ?, ?, ?, ?, ?)")) {
statement.setInt(1, operatorId);
statement.setString(2, operatorName != null ? operatorName : "");
statement.setString(3, action != null ? action : "");
statement.setInt(4, targetUserId);
statement.setString(5, truncate(detail));
statement.setString(6, ip != null ? ip : "");
statement.setInt(7, Emulator.getIntUnixTimestamp());
statement.execute();
} catch (SQLException e) {
LOGGER.error("Failed to write housekeeping audit log entry", e);
}
}
private static String truncate(String detail) {
if (detail == null) return "";
return detail.length() > 512 ? detail.substring(0, 512) : detail;
}
private static void ensureTable() {
if (tableReady) {
return;
}
synchronized (HousekeepingAuditLog.class) {
if (tableReady) {
return;
}
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
Statement statement = connection.createStatement()) {
statement.execute(
"CREATE TABLE IF NOT EXISTS housekeeping_log (" +
"id INT UNSIGNED NOT NULL AUTO_INCREMENT, " +
"operator_id INT NOT NULL, " +
"operator_name VARCHAR(64) NOT NULL DEFAULT '', " +
"action VARCHAR(64) NOT NULL, " +
"target_user_id INT NOT NULL DEFAULT 0, " +
"detail VARCHAR(512) NOT NULL DEFAULT '', " +
"ip VARCHAR(64) NOT NULL DEFAULT '', " +
"timestamp INT NOT NULL, " +
"PRIMARY KEY (id), " +
"KEY idx_operator (operator_id), " +
"KEY idx_target (target_user_id), " +
"KEY idx_timestamp (timestamp)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
tableReady = true;
} catch (SQLException e) {
LOGGER.error("Failed to create housekeeping_log table", e);
}
}
}
}
@@ -378,7 +378,9 @@ public class ModToolManager {
statement.setString(6, reason);
statement.setString(7, type.getType());
try (ResultSet set = statement.executeQuery()) {
statement.executeUpdate();
try (ResultSet set = statement.getGeneratedKeys()) {
if (set.next()) {
try (PreparedStatement selectBanStatement = connection.prepareStatement("SELECT * FROM bans WHERE id = ? LIMIT 1")) {
selectBanStatement.setInt(1, set.getInt(1));
@@ -434,6 +436,10 @@ public class ModToolManager {
Habbo target = Emulator.getGameEnvironment().getHabboManager().getHabbo(targetUserId);
HabboInfo offlineInfo = target != null ? target.getHabboInfo() : HabboManager.getOfflineHabboInfo(targetUserId);
if (offlineInfo == null) {
return bans;
}
if (moderator.getHabboInfo().getRank().getId() < offlineInfo.getRank().getId()) {
return bans;
}
@@ -454,7 +460,7 @@ public class ModToolManager {
bans.add(ban);
if (target != null) {
Emulator.getGameServer().getGameClientManager().disposeClient(target.getClient());
Emulator.getGameServer().getGameClientManager().forceDisposeClient(target.getClient());
}
if ((type == ModToolBanType.IP || type == ModToolBanType.SUPER) && target != null && !ban.ip.equals("offline")) {
@@ -465,7 +471,7 @@ public class ModToolManager {
Emulator.getPluginManager().fireEvent(new SupportUserBannedEvent(moderator, h, ban));
Emulator.getThreading().run(ban);
bans.add(ban);
Emulator.getGameServer().getGameClientManager().disposeClient(h.getClient());
Emulator.getGameServer().getGameClientManager().forceDisposeClient(h.getClient());
}
}
@@ -477,7 +483,7 @@ public class ModToolManager {
Emulator.getPluginManager().fireEvent(new SupportUserBannedEvent(moderator, h, ban));
Emulator.getThreading().run(ban);
bans.add(ban);
Emulator.getGameServer().getGameClientManager().disposeClient(h.getClient());
Emulator.getGameServer().getGameClientManager().forceDisposeClient(h.getClient());
}
}
@@ -23,7 +23,6 @@ public class WordFilter {
private static final Logger LOGGER = LoggerFactory.getLogger(WordFilter.class);
private static final Pattern DIACRITICS_AND_FRIENDS = Pattern.compile("[\\p{InCombiningDiacriticalMarks}\\p{IsLm}\\p{IsSk}]+");
//Configuration. Loaded from database & updated accordingly.
public static boolean ENABLED_FRIENDCHAT = true;
public static String DEFAULT_REPLACEMENT = "bobba";
protected THashSet<WordFilterWord> autoReportWords = new THashSet<>();
@@ -63,10 +62,12 @@ public class WordFilter {
continue;
}
if (word.autoReport)
this.autoReportWords.add(word);
else if (word.hideMessage)
this.hideMessageWords.add(word);
if (!word.prefixOnly) {
if (word.autoReport)
this.autoReportWords.add(word);
else if (word.hideMessage)
this.hideMessageWords.add(word);
}
this.words.add(word);
}
@@ -146,6 +147,8 @@ public class WordFilter {
while (iterator.hasNext()) {
WordFilterWord word = (WordFilterWord) iterator.next();
if (word.prefixOnly) continue;
if (StringUtils.containsIgnoreCase(filteredMessage, word.key)) {
if (habbo != null) {
if (Emulator.getPluginManager().fireEvent(new UserTriggerWordFilterEvent(habbo, word)).isCancelled())
@@ -179,6 +182,8 @@ public class WordFilter {
while (iterator.hasNext()) {
WordFilterWord word = (WordFilterWord) iterator.next();
if (word.prefixOnly) continue;
if (StringUtils.containsIgnoreCase(message, word.key)) {
if (habbo != null) {
if (Emulator.getPluginManager().fireEvent(new UserTriggerWordFilterEvent(habbo, word)).isCancelled())
@@ -9,6 +9,7 @@ public class WordFilterWord {
public final boolean hideMessage;
public final boolean autoReport;
public final int muteTime;
public final boolean prefixOnly;
public WordFilterWord(ResultSet set) throws SQLException {
this.key = set.getString("key");
@@ -16,13 +17,27 @@ public class WordFilterWord {
this.hideMessage = set.getInt("hide") == 1;
this.autoReport = set.getInt("report") == 1;
this.muteTime = set.getInt("mute");
this.prefixOnly = readBooleanColumn(set, "prefix_only");
}
public WordFilterWord(String key, String replacement) {
this(key, replacement, false);
}
public WordFilterWord(String key, String replacement, boolean prefixOnly) {
this.key = key;
this.replacement = replacement;
this.hideMessage = false;
this.autoReport = false;
this.muteTime = 0;
this.prefixOnly = prefixOnly;
}
private static boolean readBooleanColumn(ResultSet set, String column) {
try {
return set.getInt(column) == 1;
} catch (SQLException e) {
return false;
}
}
}
@@ -8,6 +8,7 @@ public class Permission {
public static String ACC_SEE_WHISPERS = "acc_see_whispers";
public static String ACC_SEE_TENTCHAT = "acc_see_tentchat";
public static String ACC_SUPERWIRED = "acc_superwired";
public static String ACC_HOUSEKEEPING = "acc_housekeeping";
public static String ACC_SUPPORTTOOL = "acc_supporttool";
public static String ACC_UNKICKABLE = "acc_unkickable";
public static String ACC_GUILDGATE = "acc_guildgate";
@@ -115,19 +115,21 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
}
public final Object roomUnitLock = new Object();
public final ConcurrentHashMap<RoomTile, THashSet<HabboItem>> tileCache = new ConcurrentHashMap<>();
public final List<Integer> userVotes;
private final TIntArrayList rights;
private final TIntIntHashMap mutedHabbos;
private final TIntObjectHashMap<RoomBan> bannedHabbos;
private final Set<Game> games;
private final TIntObjectMap<RoomMoodlightData> moodlightData;
public volatile double lastCycleCpuMs = 0.0;
public volatile String lastCycleThread = "N/A";
private final Object loadLock = new Object();
//Use appropriately. Could potentially cause memory leaks when used incorrectly.
public volatile boolean preventUnloading = false;
public volatile boolean preventUncaching = false;
public Set<ServerMessage> scheduledComposers = ConcurrentHashMap.newKeySet();
public Set<Runnable> scheduledTasks = ConcurrentHashMap.newKeySet();
public final java.util.concurrent.ConcurrentLinkedQueue<Runnable> scheduledTasks = new java.util.concurrent.ConcurrentLinkedQueue<>();
public String wordQuiz = "";
public int noVotes = 0;
public int yesVotes = 0;
@@ -195,6 +197,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
private int wiredInspectMask = WIRED_ACCESS_DEFAULT_INSPECT_MASK;
private int wiredModifyMask = WIRED_ACCESS_DEFAULT_MODIFY_MASK;
private boolean youtubeEnabled = false;
private boolean soundboardEnabled = false;
private String youtubeCurrentVideo = "";
private String youtubeSenderName = "";
private final java.util.List<String> youtubePlaylist = new java.util.concurrent.CopyOnWriteArrayList<>();
@@ -202,22 +205,24 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
public boolean isYoutubeEnabled() { return this.youtubeEnabled; }
public void setYoutubeEnabled(boolean enabled) { this.youtubeEnabled = enabled; }
public boolean isSoundboardEnabled() { return this.soundboardEnabled; }
public void setSoundboardEnabled(boolean enabled) { this.soundboardEnabled = enabled; }
public String getYoutubeCurrentVideo() { return this.youtubeCurrentVideo; }
public String getYoutubeSenderName() { return this.youtubeSenderName; }
public java.util.List<String> getYoutubePlaylist() { return this.youtubePlaylist; }
public java.util.Set<Integer> getYoutubeWatchers() { return this.youtubeWatchers; }
public void setYoutubeVideo(String videoId, String senderName, java.util.List<String> playlist) {
this.youtubeCurrentVideo = videoId;
this.youtubeSenderName = senderName;
this.youtubePlaylist.clear();
if (playlist != null) this.youtubePlaylist.addAll(playlist);
this.youtubeCurrentVideo = videoId;
this.youtubeSenderName = senderName;
this.youtubePlaylist.clear();
if (playlist != null) this.youtubePlaylist.addAll(playlist);
}
public void clearYoutubeVideo() {
this.youtubeCurrentVideo = "";
this.youtubeSenderName = "";
this.youtubePlaylist.clear();
this.youtubeCurrentVideo = "";
this.youtubeSenderName = "";
this.youtubePlaylist.clear();
}
public final THashMap<String, Object> cache;
@@ -234,9 +239,9 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
this.usersMax = set.getInt("users_max");
this.score = set.getInt("score");
this.category = set.getInt("category");
this.floorPaint = set.getString("paper_floor");
this.wallPaint = set.getString("paper_wall");
this.backgroundPaint = set.getString("paper_landscape");
this.floorPaint = set.getString("paper_floor") == null ? "0.0" : set.getString("paper_floor");
this.wallPaint = set.getString("paper_wall") == null ? "0.0" : set.getString("paper_wall");
this.backgroundPaint = set.getString("paper_landscape") == null ? "0.0" : set.getString("paper_landscape");
this.wallSize = set.getInt("thickness_wall");
this.wallHeight = set.getInt("wall_height");
this.floorSize = set.getInt("thickness_floor");
@@ -248,6 +253,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
this.allowWalkthrough = set.getBoolean("allow_walkthrough");
this.hideWall = set.getBoolean("allow_hidewall");
try { this.youtubeEnabled = set.getBoolean("youtube_enabled"); } catch (Exception e) { this.youtubeEnabled = false; }
try { this.soundboardEnabled = set.getBoolean("soundboard_enabled"); } catch (Exception e) { this.soundboardEnabled = false; }
this.chatMode = set.getInt("chat_mode");
this.chatWeight = set.getInt("chat_weight");
this.chatSpeed = set.getInt("chat_speed");
@@ -458,7 +464,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
if (this.loaded || this.loadingInProgress || !this.preLoaded) {
return;
}
this.loadingInProgress = true;
this.loadingFuture = CompletableFuture.runAsync(() -> {
this.loadDataInternal();
@@ -478,7 +484,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
}
future = this.loadingFuture;
}
if (future != null) {
try {
future.join();
@@ -493,7 +499,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
public void loadData() {
CompletableFuture<Void> futureToWait = null;
boolean shouldLoad = false;
synchronized (this.loadLock) {
if (this.loadingInProgress) {
// Get the future to wait on outside the lock
@@ -503,7 +509,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
shouldLoad = true;
}
}
// Wait for existing load outside the lock
if (futureToWait != null) {
try {
@@ -513,7 +519,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
}
return;
}
// Load if needed
if (shouldLoad) {
this.loadDataInternal();
@@ -553,7 +559,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
CompletableFuture.runAsync(() -> {
try (Connection promoConnection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement stmt = promoConnection.prepareStatement(
"SELECT * FROM room_promotions WHERE room_id = ? AND end_timestamp > ? LIMIT 1")) {
"SELECT * FROM room_promotions WHERE room_id = ? AND end_timestamp > ? LIMIT 1")) {
stmt.setInt(1, this.id);
stmt.setInt(2, Emulator.getIntUnixTimestamp());
try (ResultSet promoSet = stmt.executeQuery()) {
@@ -648,7 +654,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
}
this.roomCycleTask = Emulator.getThreading().getService()
.scheduleAtFixedRate(this, 500, 500, TimeUnit.MILLISECONDS);
.scheduleAtFixedRate(this, 500, 500, TimeUnit.MILLISECONDS);
} catch (Exception e) {
LOGGER.error("Caught exception during room load", e);
}
@@ -667,7 +673,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
item.setExtradata("1");
this.updateItem(item);
}
// Set loaded flag with lock
synchronized (this.loadLock) {
this.loaded = true;
@@ -684,7 +690,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
this.layout = Emulator.getGameEnvironment().getRoomManager().loadCustomLayout(this);
} else {
this.layout = Emulator.getGameEnvironment().getRoomManager()
.loadLayout(this.layoutName, this);
.loadLayout(this.layoutName, this);
}
}
}
@@ -716,7 +722,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
this.unitManager.clearBots();
try (PreparedStatement statement = connection.prepareStatement(
"SELECT users.username AS owner_name, bots.* FROM bots INNER JOIN users ON bots.user_id = users.id WHERE room_id = ?")) {
"SELECT users.username AS owner_name, bots.* FROM bots INNER JOIN users ON bots.user_id = users.id WHERE room_id = ?")) {
statement.setInt(1, this.id);
try (ResultSet set = statement.executeQuery()) {
while (set.next()) {
@@ -727,11 +733,11 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
b.setRoomUnit(new RoomUnit());
b.getRoomUnit().setPathFinderRoom(this);
b.getRoomUnit()
.setLocation(this.layout.getTile((short) set.getInt("x"), (short) set.getInt("y")));
.setLocation(this.layout.getTile((short) set.getInt("x"), (short) set.getInt("y")));
if (b.getRoomUnit().getCurrentLocation() == null) {
b.getRoomUnit().setLocation(this.getLayout().getDoorTile());
b.getRoomUnit()
.setRotation(RoomUserRotation.fromValue(this.getLayout().getDoorDirection()));
.setRotation(RoomUserRotation.fromValue(this.getLayout().getDoorDirection()));
} else {
b.getRoomUnit().setZ(set.getDouble("z"));
b.getRoomUnit().setPreviousLocationZ(set.getDouble("z"));
@@ -755,7 +761,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
this.unitManager.clearPets();
try (PreparedStatement statement = connection.prepareStatement(
"SELECT users.username as pet_owner_name, users_pets.* FROM users_pets INNER JOIN users ON users_pets.user_id = users.id WHERE room_id = ?")) {
"SELECT users.username as pet_owner_name, users_pets.* FROM users_pets INNER JOIN users ON users_pets.user_id = users.id WHERE room_id = ?")) {
statement.setInt(1, this.id);
try (ResultSet set = statement.executeQuery()) {
while (set.next()) {
@@ -765,11 +771,11 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
pet.setRoomUnit(new RoomUnit());
pet.getRoomUnit().setPathFinderRoom(this);
pet.getRoomUnit()
.setLocation(this.layout.getTile((short) set.getInt("x"), (short) set.getInt("y")));
.setLocation(this.layout.getTile((short) set.getInt("x"), (short) set.getInt("y")));
if (pet.getRoomUnit().getCurrentLocation() == null) {
pet.getRoomUnit().setLocation(this.getLayout().getDoorTile());
pet.getRoomUnit()
.setRotation(RoomUserRotation.fromValue(this.getLayout().getDoorDirection()));
.setRotation(RoomUserRotation.fromValue(this.getLayout().getDoorDirection()));
} else {
pet.getRoomUnit().setZ(set.getDouble("z"));
pet.getRoomUnit().setRotation(RoomUserRotation.values()[set.getInt("rot")]);
@@ -843,7 +849,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
THashSet<RoomTile> updatedTiles = new THashSet<>();
Rectangle rectangle = RoomLayout.getRectangle(item.getX(), item.getY(),
item.getBaseItem().getWidth(), item.getBaseItem().getLength(), item.getRotation());
item.getBaseItem().getWidth(), item.getBaseItem().getLength(), item.getRotation());
for (short x = (short) rectangle.x; x < rectangle.x + rectangle.getWidth(); x++) {
for (short y = (short) rectangle.y; y < rectangle.y + rectangle.getHeight(); y++) {
@@ -872,7 +878,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
}
Habbo habbo = (picker != null && picker.getHabboInfo().getId() == item.getId() ? picker
: Emulator.getGameServer().getGameClientManager().getHabbo(item.getUserId()));
: Emulator.getGameServer().getGameClientManager().getHabbo(item.getUserId()));
if (!trackedBuildersClubItem && habbo != null) {
habbo.getInventory().getItemsComponent().addItem(item);
habbo.getClient().sendResponse(new AddHabboItemComposer(item));
@@ -981,8 +987,6 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
this.scheduledTasks.clear();
this.scheduledComposers.clear();
this.tileCache.clear();
synchronized (this.mutedHabbos) {
this.mutedHabbos.clear();
}
@@ -1112,7 +1116,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
message.appendInt(this.category);
String[] tags = Arrays.stream(this.tags.split(";")).filter(t -> !t.isEmpty())
.toArray(String[]::new);
.toArray(String[]::new);
message.appendInt(tags.length);
for (String s : tags) {
message.appendString(s);
@@ -1160,10 +1164,13 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
synchronized (this.loadLock) {
if (this.loaded) {
try {
long startTime = System.nanoTime();
this.lastCycleThread = Thread.currentThread().getName();
// Run cycle directly instead of scheduling on thread pool
// This ensures all cycle tasks in the same tick execute synchronously
// preventing wired desync issues
this.cycle();
this.lastCycleCpuMs = (System.nanoTime() - startTime) / 1000000.0;
} catch (Exception e) {
LOGGER.error("Caught exception", e);
}
@@ -1176,8 +1183,8 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
public void save() {
if (this.needsUpdate) {
try (Connection connection = Emulator.getDatabase().getDataSource()
.getConnection(); PreparedStatement statement = connection.prepareStatement(
"UPDATE rooms SET name = ?, description = ?, password = ?, state = ?, users_max = ?, category = ?, score = ?, paper_floor = ?, paper_wall = ?, paper_landscape = ?, thickness_wall = ?, wall_height = ?, thickness_floor = ?, moodlight_data = ?, tags = ?, allow_other_pets = ?, allow_other_pets_eat = ?, allow_walkthrough = ?, allow_hidewall = ?, chat_mode = ?, chat_weight = ?, chat_speed = ?, chat_hearing_distance = ?, chat_protection =?, who_can_mute = ?, who_can_kick = ?, who_can_ban = ?, poll_id = ?, guild_id = ?, roller_speed = ?, override_model = ?, is_staff_picked = ?, promoted = ?, trade_mode = ?, move_diagonally = ?, owner_id = ?, owner_name = ?, jukebox_active = ?, hidewired = ?, allow_underpass = ?, youtube_enabled = ?, builders_club_trial_locked = ?, builders_club_original_state = ? WHERE id = ?")) {
.getConnection(); PreparedStatement statement = connection.prepareStatement(
"UPDATE rooms SET name = ?, description = ?, password = ?, state = ?, users_max = ?, category = ?, score = ?, paper_floor = ?, paper_wall = ?, paper_landscape = ?, thickness_wall = ?, wall_height = ?, thickness_floor = ?, moodlight_data = ?, tags = ?, allow_other_pets = ?, allow_other_pets_eat = ?, allow_walkthrough = ?, allow_hidewall = ?, chat_mode = ?, chat_weight = ?, chat_speed = ?, chat_hearing_distance = ?, chat_protection =?, who_can_mute = ?, who_can_kick = ?, who_can_ban = ?, poll_id = ?, guild_id = ?, roller_speed = ?, override_model = ?, is_staff_picked = ?, promoted = ?, trade_mode = ?, move_diagonally = ?, owner_id = ?, owner_name = ?, jukebox_active = ?, hidewired = ?, allow_underpass = ?, youtube_enabled = ?, builders_club_trial_locked = ?, builders_club_original_state = ? WHERE id = ?")) {
statement.setString(1, this.name);
statement.setString(2, this.description);
statement.setString(3, this.password);
@@ -1245,8 +1252,8 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
*/
public void updateDatabaseUserCount() {
try (Connection connection = Emulator.getDatabase().getDataSource()
.getConnection(); PreparedStatement statement = connection.prepareStatement(
"UPDATE rooms SET users = ? WHERE id = ? LIMIT 1")) {
.getConnection(); PreparedStatement statement = connection.prepareStatement(
"UPDATE rooms SET users = ? WHERE id = ? LIMIT 1")) {
statement.setInt(1, this.getUserCount());
statement.setInt(2, this.id);
statement.executeUpdate();
@@ -1486,6 +1493,10 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
return this.getGuildId() != 0;
}
public boolean belongsToGuild() {
return this.guild > 0;
}
public void setGuild(int guild) {
this.guild = guild;
}
@@ -1593,7 +1604,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
if (extraData.length == 4) {
if (extraData[0].equalsIgnoreCase("1")) {
return Color.getHSBColor(Integer.parseInt(extraData[1]),
Integer.parseInt(extraData[2]), Integer.parseInt(extraData[3]));
Integer.parseInt(extraData[2]), Integer.parseInt(extraData[3]));
}
}
}
@@ -1700,7 +1711,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
public String[] filterAnything() {
return new String[]{this.getOwnerName(), this.getGuildName(), this.getDescription(),
this.getPromotionDesc()};
this.getPromotionDesc()};
}
public long getCycleTimestamp() {
@@ -1907,7 +1918,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
// If the broadcast sender leaves, stop the broadcast for everyone
if (!this.youtubeCurrentVideo.isEmpty()
&& habbo.getHabboInfo().getUsername().equals(this.youtubeSenderName)) {
&& habbo.getHabboInfo().getUsername().equals(this.youtubeSenderName)) {
this.clearYoutubeVideo();
this.sendComposer(new com.eu.habbo.messages.outgoing.rooms.youtube.YouTubeRoomBroadcastComposer("", "", java.util.Collections.emptyList()).compose());
}
@@ -2052,7 +2063,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
}
public void talk(final Habbo habbo, final RoomChatMessage roomChatMessage, RoomChatType chatType,
boolean ignoreWired) {
boolean ignoreWired) {
this.chatManager.talk(habbo, roomChatMessage, chatType, ignoreWired);
}
@@ -2197,7 +2208,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
private void loadRights(Connection connection) {
this.rights.clear();
try (PreparedStatement statement = connection.prepareStatement(
"SELECT user_id FROM room_rights WHERE room_id = ?")) {
"SELECT user_id FROM room_rights WHERE room_id = ?")) {
statement.setInt(1, this.id);
try (ResultSet set = statement.executeQuery()) {
while (set.next()) {
@@ -2213,7 +2224,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
this.bannedHabbos.clear();
try (PreparedStatement statement = connection.prepareStatement(
"SELECT users.username, users.id, room_bans.* FROM room_bans INNER JOIN users ON room_bans.user_id = users.id WHERE ends > ? AND room_bans.room_id = ?")) {
"SELECT users.username, users.id, room_bans.* FROM room_bans INNER JOIN users ON room_bans.user_id = users.id WHERE ends > ? AND room_bans.room_id = ?")) {
statement.setInt(1, Emulator.getIntUnixTimestamp());
statement.setInt(2, this.id);
try (ResultSet set = statement.executeQuery()) {
@@ -2320,27 +2331,37 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
sanitizedInspectMask |= sanitizedModifyMask;
synchronized (this.wiredSettingsLock) {
int previousInspectMask = this.wiredInspectMask;
int previousModifyMask = this.wiredModifyMask;
final int finalInspectMask = sanitizedInspectMask;
final int finalModifyMask = sanitizedModifyMask;
final int finalId = this.id;
final int previousInspectMask = this.wiredInspectMask;
final int previousModifyMask = this.wiredModifyMask;
this.wiredInspectMask = sanitizedInspectMask;
this.wiredModifyMask = sanitizedModifyMask;
this.wiredSettingsLoaded = true;
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO room_wired_settings (room_id, inspect_mask, modify_mask) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE inspect_mask = VALUES(inspect_mask), modify_mask = VALUES(modify_mask)")) {
statement.setInt(1, this.id);
statement.setInt(2, sanitizedInspectMask);
statement.setInt(3, sanitizedModifyMask);
statement.executeUpdate();
this.pushWiredSettingsToCurrentHabbos();
return true;
} catch (SQLException e) {
this.wiredInspectMask = previousInspectMask;
this.wiredModifyMask = previousModifyMask;
LOGGER.error("Caught SQL exception while saving wired room settings", e);
return false;
}
Emulator.getThreading().run(() -> {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(
"INSERT INTO room_wired_settings (room_id, inspect_mask, modify_mask) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE inspect_mask = VALUES(inspect_mask), modify_mask = VALUES(modify_mask)")) {
statement.setInt(1, finalId);
statement.setInt(2, finalInspectMask);
statement.setInt(3, finalModifyMask);
statement.executeUpdate();
} catch (SQLException e) {
synchronized (this.wiredSettingsLock) {
if (this.wiredInspectMask == finalInspectMask && this.wiredModifyMask == finalModifyMask) {
this.wiredInspectMask = previousInspectMask;
this.wiredModifyMask = previousModifyMask;
}
}
LOGGER.error("Caught SQL exception while saving wired room settings", e);
}
});
this.pushWiredSettingsToCurrentHabbos();
return true;
}
}
@@ -2413,7 +2434,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement(
"SELECT inspect_mask, modify_mask FROM room_wired_settings WHERE room_id = ? LIMIT 1")) {
"SELECT inspect_mask, modify_mask FROM room_wired_settings WHERE room_id = ? LIMIT 1")) {
statement.setInt(1, this.id);
try (ResultSet set = statement.executeQuery()) {
@@ -2513,15 +2534,15 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
}
if (habbo.getRoomUnit().hasStatus(RoomUnitStatus.SIT) || !habbo.getRoomUnit()
.canForcePosture()) {
.canForcePosture()) {
return;
}
this.dance(habbo, DanceType.NONE);
habbo.getRoomUnit().cmdSit = true;
habbo.getRoomUnit().setBodyRotation(
RoomUserRotation.values()[habbo.getRoomUnit().getBodyRotation().getValue()
- habbo.getRoomUnit().getBodyRotation().getValue() % 2]);
RoomUserRotation.values()[habbo.getRoomUnit().getBodyRotation().getValue()
- habbo.getRoomUnit().getBodyRotation().getValue() % 2]);
habbo.getRoomUnit().setStatus(RoomUnitStatus.SIT, 0.5 + "");
this.sendComposer(new RoomUserStatusComposer(habbo.getRoomUnit()).compose());
WiredManager.triggerUserPerformsAction(this, habbo.getRoomUnit(), WiredUserActionType.SIT, -1);
@@ -2535,11 +2556,11 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
HabboItem item = this.getTopItemAt(habbo.getRoomUnit().getX(), habbo.getRoomUnit().getY());
if (item == null || !item.getBaseItem().allowSit() || !item.getBaseItem().allowLay()) {
boolean wasSittingOrLaying = habbo.getRoomUnit().hasStatus(RoomUnitStatus.SIT)
|| habbo.getRoomUnit().hasStatus(RoomUnitStatus.LAY);
|| habbo.getRoomUnit().hasStatus(RoomUnitStatus.LAY);
habbo.getRoomUnit().cmdStand = true;
habbo.getRoomUnit().setBodyRotation(
RoomUserRotation.values()[habbo.getRoomUnit().getBodyRotation().getValue()
- habbo.getRoomUnit().getBodyRotation().getValue() % 2]);
RoomUserRotation.values()[habbo.getRoomUnit().getBodyRotation().getValue()
- habbo.getRoomUnit().getBodyRotation().getValue() % 2]);
habbo.getRoomUnit().removeStatus(RoomUnitStatus.SIT);
habbo.getRoomUnit().removeStatus(RoomUnitStatus.LAY);
this.sendComposer(new RoomUserStatusComposer(habbo.getRoomUnit()).compose());
@@ -2567,38 +2588,38 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
}
public void updateItem(HabboItem item) {
if (this.isLoaded()) {
if (item != null && item.getRoomId() == this.id) {
if (item.getBaseItem() != null) {
if (item.getBaseItem().getType() == FurnitureType.FLOOR) {
this.sendComposer(new FloorItemUpdateComposer(item).compose());
this.updateTiles(this.getLayout()
.getTilesAt(this.layout.getTile(item.getX(), item.getY()),
item.getBaseItem().getWidth(), item.getBaseItem().getLength(),
item.getRotation()));
if (this.isLoaded()) {
if (item != null && item.getRoomId() == this.id) {
if (item.getBaseItem() != null) {
if (item.getBaseItem().getType() == FurnitureType.FLOOR) {
this.sendComposer(new FloorItemUpdateComposer(item).compose());
this.updateTiles(this.getLayout()
.getTilesAt(this.layout.getTile(item.getX(), item.getY()),
item.getBaseItem().getWidth(), item.getBaseItem().getLength(),
item.getRotation()));
if (RoomAreaHideSupport.isControllerItem(item)) {
RoomAreaHideSupport.sendState(this, item);
}
} else if (item.getBaseItem().getType() == FurnitureType.WALL) {
this.sendComposer(new WallItemUpdateComposer(item).compose());
if (RoomAreaHideSupport.isControllerItem(item)) {
RoomAreaHideSupport.sendState(this, item);
}
} else if (item.getBaseItem().getType() == FurnitureType.WALL) {
this.sendComposer(new WallItemUpdateComposer(item).compose());
}
}
}
}
}
public void updateItemState(HabboItem item) {
if (item != null && RoomAreaHideSupport.isControllerItem(item)) {
this.updateItem(item);
return;
}
if (item != null && RoomAreaHideSupport.isControllerItem(item)) {
this.updateItem(item);
return;
}
if (!item.isLimited()) {
this.sendComposer(new ItemStateComposer(item).compose());
} else {
this.sendComposer(new FloorItemUpdateComposer(item).compose());
}
if (!item.isLimited()) {
this.sendComposer(new ItemStateComposer(item).compose());
} else {
this.sendComposer(new FloorItemUpdateComposer(item).compose());
}
if (item.getBaseItem().getType() == FurnitureType.FLOOR) {
if (this.layout == null) {
@@ -2606,8 +2627,8 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
}
this.updateTiles(this.getLayout()
.getTilesAt(this.layout.getTile(item.getX(), item.getY()), item.getBaseItem().getWidth(),
item.getBaseItem().getLength(), item.getRotation()));
.getTilesAt(this.layout.getTile(item.getX(), item.getY()), item.getBaseItem().getWidth(),
item.getBaseItem().getLength(), item.getRotation()));
if (item instanceof InteractionMultiHeight) {
((InteractionMultiHeight) item).updateUnitsOnItem(this);
@@ -2615,12 +2636,12 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
}
if (item.getBaseItem().getType() == FurnitureType.FLOOR
&& (RoomConfInvisSupport.isControllerItem(item) || RoomConfInvisSupport.isTarget(item))) {
&& (RoomConfInvisSupport.isControllerItem(item) || RoomConfInvisSupport.isTarget(item))) {
RoomConfInvisSupport.sendState(this);
}
if (item.getBaseItem().getType() == FurnitureType.FLOOR
&& RoomHanditemBlockSupport.isControllerItem(item)) {
&& RoomHanditemBlockSupport.isControllerItem(item)) {
RoomHanditemBlockSupport.sendState(this);
}
}
@@ -2654,18 +2675,18 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
public void refreshGuild(Guild guild) {
if (guild.getRoomId() == this.id) {
THashSet<GuildMember> members = Emulator.getGameEnvironment().getGuildManager()
.getGuildMembers(guild.getId());
.getGuildMembers(guild.getId());
for (Habbo habbo : this.getHabbos()) {
Optional<GuildMember> member = members.stream()
.filter(m -> m.getUserId() == habbo.getHabboInfo().getId()).findAny();
.filter(m -> m.getUserId() == habbo.getHabboInfo().getId()).findAny();
if (!member.isPresent()) {
continue;
}
habbo.getClient()
.sendResponse(new GuildInfoComposer(guild, habbo.getClient(), false, member.get()));
.sendResponse(new GuildInfoComposer(guild, habbo.getClient(), false, member.get()));
}
}
@@ -2700,7 +2721,7 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
if (habbo.getHabboInfo().getCurrentRoom() == this) {
if (habbo.getHabboInfo().getId() != this.ownerId) {
if (!(habbo.hasPermission(Permission.ACC_ANYROOMOWNER) || habbo.hasPermission(
Permission.ACC_MOVEROTATE))) {
Permission.ACC_MOVEROTATE))) {
this.refreshRightsForHabbo(habbo);
}
}
@@ -2786,18 +2807,18 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
}
} else {
this.sendComposer(new RoomFloorItemsComposer(this.itemManager.getFurniOwnerNames(),
this.roomSpecialTypes.getTriggers()).compose());
this.roomSpecialTypes.getTriggers()).compose());
this.sendComposer(new RoomFloorItemsComposer(this.itemManager.getFurniOwnerNames(),
this.roomSpecialTypes.getEffects()).compose());
this.roomSpecialTypes.getEffects()).compose());
this.sendComposer(new RoomFloorItemsComposer(this.itemManager.getFurniOwnerNames(),
this.roomSpecialTypes.getConditions()).compose());
this.roomSpecialTypes.getConditions()).compose());
this.sendComposer(new RoomFloorItemsComposer(this.itemManager.getFurniOwnerNames(),
this.roomSpecialTypes.getExtras()).compose());
this.roomSpecialTypes.getExtras()).compose());
}
}
public FurnitureMovementError canPlaceFurnitureAt(HabboItem item, Habbo habbo, RoomTile tile,
int rotation) {
int rotation) {
return this.itemManager.canPlaceFurnitureAt(item, habbo, tile, rotation);
}
@@ -2806,17 +2827,17 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
}
public FurnitureMovementError furnitureFitsAt(RoomTile tile, HabboItem item, int rotation,
boolean checkForUnits) {
boolean checkForUnits) {
return this.itemManager.furnitureFitsAt(tile, item, rotation, checkForUnits);
}
public FurnitureMovementError furnitureFitsAtWithPhysics(RoomTile tile, HabboItem item, int rotation,
boolean checkForUnits, WiredMovementPhysics physics) {
boolean checkForUnits, WiredMovementPhysics physics) {
return this.itemManager.furnitureFitsAtWithPhysics(tile, item, rotation, checkForUnits, physics);
}
public FurnitureMovementError placeFloorFurniAt(HabboItem item, RoomTile tile, int rotation,
Habbo owner) {
Habbo owner) {
return this.itemManager.placeFloorFurniAt(item, tile, rotation, owner);
}
@@ -2825,17 +2846,17 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
}
public FurnitureMovementError moveFurniTo(HabboItem item, RoomTile tile, int rotation,
Habbo actor) {
Habbo actor) {
return this.itemManager.moveFurniTo(item, tile, rotation, actor);
}
public FurnitureMovementError moveFurniTo(HabboItem item, RoomTile tile, int rotation,
Habbo actor, boolean sendUpdates) {
Habbo actor, boolean sendUpdates) {
return this.itemManager.moveFurniTo(item, tile, rotation, actor, sendUpdates);
}
public FurnitureMovementError moveFurniTo(HabboItem item, RoomTile tile, int rotation,
Habbo actor, boolean sendUpdates, boolean checkForUnits) {
Habbo actor, boolean sendUpdates, boolean checkForUnits) {
return this.itemManager.moveFurniTo(item, tile, rotation, actor, sendUpdates, checkForUnits);
}
@@ -2852,12 +2873,12 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
}
public FurnitureMovementError moveFurniToWithPhysics(HabboItem item, RoomTile tile, int rotation,
Habbo actor, boolean sendUpdates, boolean checkForUnits, WiredMovementPhysics physics) {
Habbo actor, boolean sendUpdates, boolean checkForUnits, WiredMovementPhysics physics) {
return this.itemManager.moveFurniToWithPhysics(item, tile, rotation, actor, sendUpdates, checkForUnits, physics);
}
public FurnitureMovementError moveFurniToWithPhysics(HabboItem item, RoomTile tile, int rotation, double z,
Habbo actor, boolean sendUpdates, boolean checkForUnits, WiredMovementPhysics physics) {
Habbo actor, boolean sendUpdates, boolean checkForUnits, WiredMovementPhysics physics) {
return this.itemManager.moveFurniToWithPhysics(item, tile, rotation, z, actor, sendUpdates, checkForUnits, physics);
}
@@ -2878,4 +2899,20 @@ public class Room implements Comparable<Room>, ISerialize, Runnable {
public Collection<RoomUnit> getRoomUnitsAt(RoomTile tile) {
return this.unitManager.getRoomUnitsAt(tile);
}
public long getEstimatedMemoryUsage() {
long bytes = 1024 * 10; // Base footprint
if (this.itemManager != null) {
bytes += this.itemManager.itemCount() * 512L;
}
bytes += this.getUserCount() * 2048L;
if (this.layout != null) {
bytes += this.layout.getMapSize() * 128L;
}
com.eu.habbo.habbohotel.wired.tick.WiredTickService wired = com.eu.habbo.habbohotel.wired.tick.WiredTickService.getInstance();
if (wired != null) {
bytes += wired.getTickableCount(this.getId()) * 256L;
}
return bytes;
}
}
@@ -313,27 +313,6 @@ public class RoomChatManager {
}
}
String wiredSayMessage = roomChatMessage.getMessage();
// Handle commands and wired
boolean suppressSaysOutput = false;
if (chatType != RoomChatType.WHISPER) {
if (CommandHandler.handleCommand(habbo.getClient(), roomChatMessage.getUnfilteredMessage())) {
WiredManager.triggerUserSays(habbo.getHabboInfo().getCurrentRoom(), habbo.getRoomUnit(), wiredSayMessage);
roomChatMessage.isCommand = true;
return;
}
if (!ignoreWired) {
suppressSaysOutput = WiredManager.shouldSuppressUserSaysOutput(
habbo.getHabboInfo().getCurrentRoom(),
habbo.getRoomUnit(),
wiredSayMessage,
chatType.ordinal(),
roomChatMessage.getBubble() != null ? roomChatMessage.getBubble().getType() : -1);
}
}
// Flood protection
if (!habbo.hasPermission(Permission.ACC_CHAT_NO_FLOOD)) {
final int chatCounter = habbo.getHabboStats().chatCounter.addAndGet(1);
@@ -357,6 +336,27 @@ public class RoomChatManager {
}
}
String wiredSayMessage = roomChatMessage.getMessage();
// Handle commands and wired
boolean suppressSaysOutput = false;
if (chatType != RoomChatType.WHISPER) {
if (CommandHandler.handleCommand(habbo.getClient(), roomChatMessage.getUnfilteredMessage())) {
WiredManager.triggerUserSays(habbo.getHabboInfo().getCurrentRoom(), habbo.getRoomUnit(), wiredSayMessage);
roomChatMessage.isCommand = true;
return;
}
if (!ignoreWired) {
suppressSaysOutput = WiredManager.shouldSuppressUserSaysOutput(
habbo.getHabboInfo().getCurrentRoom(),
habbo.getRoomUnit(),
wiredSayMessage,
chatType.ordinal(),
roomChatMessage.getBubble() != null ? roomChatMessage.getBubble().getType() : -1);
}
}
// Build prefix messages
ServerMessage prefixMessage = null;
@@ -615,6 +615,9 @@ public class RoomChatManager {
InteractionTalkingFurniture.class);
for (HabboItem item : items) {
if (item.getExtradata().equals("1")) {
continue;
}
if (this.room.getLayout().getTile(item.getX(), item.getY())
.distance(habbo.getRoomUnit().getCurrentLocation()) <= Emulator.getConfig()
.getInt("furniture.talking.range")) {
@@ -134,7 +134,18 @@ public class RoomChatMessageBubbles {
}
public static RoomChatMessageBubbles getBubble(int id) {
return BUBBLES.getOrDefault(id, NORMAL);
RoomChatMessageBubbles bubble = BUBBLES.get(id);
if (bubble != null) return bubble;
// Custom chat bubbles (client-side only, e.g. ids 253+) are not registered
// above. Instead of falling back to NORMAL (which made them render as the
// default bubble), pass the id through so the server relays it as-is and
// the client renders its own .bubble-<id> style. Capped to avoid abuse.
if (id > 0 && id <= 1000) {
return new RoomChatMessageBubbles(id, "CUSTOM_" + id, "", true, false);
}
return NORMAL;
}
private static void registerBubble(RoomChatMessageBubbles bubble) {
@@ -75,7 +75,6 @@ public class RoomCycleManager {
final boolean[] foundRightHolder = {false};
boolean loaded = this.room.isLoaded();
this.room.tileCache.clear();
if (loaded) {
processScheduledTasks();
@@ -164,13 +163,9 @@ public class RoomCycleManager {
* Processes scheduled tasks.
*/
private void processScheduledTasks() {
if (!this.room.scheduledTasks.isEmpty()) {
Set<Runnable> tasks = this.room.scheduledTasks;
this.room.scheduledTasks = ConcurrentHashMap.newKeySet();
for (Runnable runnable : tasks) {
Emulator.getThreading().run(runnable);
}
Runnable task;
while ((task = this.room.scheduledTasks.poll()) != null) {
Emulator.getThreading().run(task);
}
}
@@ -305,15 +300,20 @@ public class RoomCycleManager {
return;
}
TIntObjectIterator<Bot> botIterator = currentBots.iterator();
for (int i = currentBots.size(); i-- > 0; ) {
// Snapshot under the map monitor (currentBots is a synchronizedMap whose
// iterator isn't concurrency-safe), then cycle OFF-lock. Holding the
// monitor across the whole tick would block bot place/pickup and room
// dispose for the tick duration AND invert the lock order vs
// roomUnitLock -> currentBots taken by RoomUnitManager.addBot/clear.
final ArrayList<Bot> bots;
synchronized (currentBots) {
bots = new ArrayList<>(currentBots.valueCollection());
}
for (Bot bot : bots) {
try {
final Bot bot;
try {
botIterator.advance();
bot = botIterator.value();
} catch (Exception e) {
break;
if (bot == null || bot.getRoomUnit() == null) {
continue;
}
if (!this.room.isAllowBotsWalk() && bot.getRoomUnit().isWalking()) {
@@ -327,10 +327,8 @@ public class RoomCycleManager {
if (this.cycleRoomUnit(bot.getRoomUnit(), RoomUnitType.BOT)) {
updatedUnit.add(bot.getRoomUnit());
}
} catch (NoSuchElementException e) {
} catch (Exception e) {
LOGGER.error("Caught exception", e);
break;
}
}
}
@@ -344,31 +342,37 @@ public class RoomCycleManager {
return;
}
TIntObjectIterator<Pet> petIterator = currentPets.iterator();
for (int i = currentPets.size(); i-- > 0; ) {
// Snapshot under the monitor, then cycle off-lock (see processBots): avoids
// holding currentPets for the whole tick and the roomUnitLock inversion.
final ArrayList<Pet> pets;
synchronized (currentPets) {
pets = new ArrayList<>(currentPets.valueCollection());
}
for (Pet pet : pets) {
try {
petIterator.advance();
} catch (NoSuchElementException e) {
if (pet == null || pet.getRoomUnit() == null) {
continue;
}
if (this.cycleRoomUnit(pet.getRoomUnit(), RoomUnitType.PET)) {
updatedUnit.add(pet.getRoomUnit());
}
pet.cycle();
if (pet.packetUpdate) {
updatedUnit.add(pet.getRoomUnit());
pet.packetUpdate = false;
}
if (pet.getRoomUnit().isWalking() && pet.getRoomUnit().getPath().size() == 1
&& pet.getRoomUnit().hasStatus(RoomUnitStatus.GESTURE)) {
pet.getRoomUnit().removeStatus(RoomUnitStatus.GESTURE);
updatedUnit.add(pet.getRoomUnit());
}
} catch (Exception e) {
LOGGER.error("Caught exception", e);
break;
}
Pet pet = petIterator.value();
if (this.cycleRoomUnit(pet.getRoomUnit(), RoomUnitType.PET)) {
updatedUnit.add(pet.getRoomUnit());
}
pet.cycle();
if (pet.packetUpdate) {
updatedUnit.add(pet.getRoomUnit());
pet.packetUpdate = false;
}
if (pet.getRoomUnit().isWalking() && pet.getRoomUnit().getPath().size() == 1
&& pet.getRoomUnit().hasStatus(RoomUnitStatus.GESTURE)) {
pet.getRoomUnit().removeStatus(RoomUnitStatus.GESTURE);
updatedUnit.add(pet.getRoomUnit());
}
}
}
@@ -486,7 +490,7 @@ public class RoomCycleManager {
if (!unit.hasStatus(RoomUnitStatus.LAY)) {
BedProfile bedProfile = new BedProfile(topItem);
double layHeight = Item.getCurrentHeight(topItem) * 1.0D + bedProfile.getLayZOffset();
LOGGER.info("[BedProfile] item={} stackHeight={} isFlat={} isDouble={} X={} Y={} Z={}",
LOGGER.debug("[BedProfile] item={} stackHeight={} isFlat={} isDouble={} X={} Y={} Z={}",
topItem.getBaseItem().getName(), topItem.getBaseItem().getHeight(),
bedProfile.isFlat(), bedProfile.isDouble(),
bedProfile.getLayXOffset(), bedProfile.getLayYOffset(), bedProfile.getLayZOffset());
@@ -35,6 +35,7 @@ public class RoomFurniVariableManager {
private final Room room;
private final ConcurrentHashMap<Integer, ConcurrentHashMap<Integer, VariableAssignment>> activeAssignmentsByFurniId;
private volatile boolean permanentAssignmentsLoaded;
private final java.util.concurrent.atomic.AtomicBoolean broadcastRequested = new java.util.concurrent.atomic.AtomicBoolean(false);
public RoomFurniVariableManager(Room room) {
this.room = room;
@@ -591,7 +592,22 @@ public class RoomFurniVariableManager {
habbo.getClient().sendResponse(new WiredUserVariablesDataComposer(this.room.getUserVariableManager().createSnapshot(), this.createSnapshot(), this.room.getRoomVariableManager().createSnapshot()));
}
public void requestBroadcast() {
if (this.broadcastRequested.compareAndSet(false, true)) {
Emulator.getThreading().run(() -> {
this.broadcastRequested.set(false);
if (this.room.isLoaded()) {
this.broadcastSnapshotRaw();
}
}, 50);
}
}
public void broadcastSnapshot() {
this.requestBroadcast();
}
public void broadcastSnapshotRaw() {
RoomUserVariableManager.Snapshot userSnapshot = this.room.getUserVariableManager().createSnapshot();
Snapshot furniSnapshot = this.createSnapshot();
RoomVariableManager.Snapshot roomSnapshot = this.room.getRoomVariableManager().createSnapshot();
@@ -148,55 +148,8 @@ public class RoomItemManager {
item = this.roomItems.get(id);
}
// Check special types if not found in main storage
RoomSpecialTypes specialTypes = this.room.getRoomSpecialTypes();
if (item == null) {
item = specialTypes.getBanzaiTeleporter(id);
}
if (item == null) {
item = specialTypes.getTrigger(id);
}
if (item == null) {
item = specialTypes.getEffect(id);
}
if (item == null) {
item = specialTypes.getCondition(id);
}
if (item == null) {
item = specialTypes.getGameGate(id);
}
if (item == null) {
item = specialTypes.getGameScorebord(id);
}
if (item == null) {
item = specialTypes.getGameTimer(id);
}
if (item == null) {
item = specialTypes.getFreezeExitTiles().get(id);
}
if (item == null) {
item = specialTypes.getRoller(id);
}
if (item == null) {
item = specialTypes.getNest(id);
}
if (item == null) {
item = specialTypes.getPetDrink(id);
}
if (item == null) {
item = specialTypes.getPetFood(id);
item = this.room.getRoomSpecialTypes().getSpecialItem(id);
}
return item;
@@ -214,17 +167,22 @@ public class RoomItemManager {
*/
public THashSet<HabboItem> getFloorItems() {
THashSet<HabboItem> items = new THashSet<>();
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator();
// roomItems is a TCollections.synchronizedMap; its iterator is not safe
// against concurrent put/remove (item place/pickup), so hold the map
// monitor for the whole traversal, matching the mutation sites.
synchronized (this.roomItems) {
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator();
for (int i = this.roomItems.size(); i-- > 0; ) {
try {
iterator.advance();
} catch (Exception e) {
break;
}
for (int i = this.roomItems.size(); i-- > 0; ) {
try {
iterator.advance();
} catch (Exception e) {
break;
}
if (iterator.value().getBaseItem().getType() == FurnitureType.FLOOR) {
items.add(iterator.value());
if (iterator.value().getBaseItem().getType() == FurnitureType.FLOOR) {
items.add(iterator.value());
}
}
}
@@ -236,17 +194,19 @@ public class RoomItemManager {
*/
public THashSet<HabboItem> getWallItems() {
THashSet<HabboItem> items = new THashSet<>();
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator();
synchronized (this.roomItems) {
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator();
for (int i = this.roomItems.size(); i-- > 0; ) {
try {
iterator.advance();
} catch (Exception e) {
break;
}
for (int i = this.roomItems.size(); i-- > 0; ) {
try {
iterator.advance();
} catch (Exception e) {
break;
}
if (iterator.value().getBaseItem().getType() == FurnitureType.WALL) {
items.add(iterator.value());
if (iterator.value().getBaseItem().getType() == FurnitureType.WALL) {
items.add(iterator.value());
}
}
}
@@ -258,18 +218,20 @@ public class RoomItemManager {
*/
public THashSet<HabboItem> getPostItNotes() {
THashSet<HabboItem> items = new THashSet<>();
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator();
synchronized (this.roomItems) {
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator();
for (int i = this.roomItems.size(); i-- > 0; ) {
try {
iterator.advance();
} catch (Exception e) {
break;
}
for (int i = this.roomItems.size(); i-- > 0; ) {
try {
iterator.advance();
} catch (Exception e) {
break;
}
if (iterator.value().getBaseItem().getInteractionType().getType()
== InteractionPostIt.class) {
items.add(iterator.value());
if (iterator.value().getBaseItem().getInteractionType().getType()
== InteractionPostIt.class) {
items.add(iterator.value());
}
}
}
@@ -323,44 +285,49 @@ public class RoomItemManager {
}
}
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator();
// Cache miss: iterate roomItems under its monitor so a concurrent
// place/pickup can't rehash the map mid-traversal (which the per-advance
// try/catch would otherwise silently swallow into an incomplete result).
synchronized (this.roomItems) {
TIntObjectIterator<HabboItem> iterator = this.roomItems.iterator();
for (int i = this.roomItems.size(); i-- > 0; ) {
HabboItem item;
try {
iterator.advance();
item = iterator.value();
} catch (Exception e) {
break;
}
for (int i = this.roomItems.size(); i-- > 0; ) {
HabboItem item;
try {
iterator.advance();
item = iterator.value();
} catch (Exception e) {
break;
}
if (item == null) {
continue;
}
if (item == null) {
continue;
}
if (item.getBaseItem().getType() != FurnitureType.FLOOR) {
continue;
}
if (item.getBaseItem().getType() != FurnitureType.FLOOR) {
continue;
}
int width, length;
int width, length;
if (item.getRotation() != 2 && item.getRotation() != 6) {
width = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1;
length = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1;
} else {
width = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1;
length = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1;
}
if (item.getRotation() != 2 && item.getRotation() != 6) {
width = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1;
length = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1;
} else {
width = item.getBaseItem().getLength() > 0 ? item.getBaseItem().getLength() : 1;
length = item.getBaseItem().getWidth() > 0 ? item.getBaseItem().getWidth() : 1;
}
if (!(tile.x >= item.getX() && tile.x <= item.getX() + width - 1 && tile.y >= item.getY()
&& tile.y <= item.getY() + length - 1)) {
continue;
}
if (!(tile.x >= item.getX() && tile.x <= item.getX() + width - 1 && tile.y >= item.getY()
&& tile.y <= item.getY() + length - 1)) {
continue;
}
items.add(item);
items.add(item);
if (returnOnFirst) {
return items;
if (returnOnFirst) {
return items;
}
}
}
@@ -726,7 +693,7 @@ public class RoomItemManager {
item instanceof WiredBlob ||
item instanceof InteractionTent ||
item instanceof InteractionSnowboardSlope ||
item instanceof InteractionFireworks) {
item instanceof InteractionFireworks || item instanceof InteractionVoteCounter) {
specialTypes.addUndefined(item);
}
}
@@ -899,7 +866,7 @@ public class RoomItemManager {
item instanceof InteractionStickyPole ||
item instanceof WiredBlob ||
item instanceof InteractionTent ||
item instanceof InteractionSnowboardSlope) {
item instanceof InteractionSnowboardSlope || item instanceof InteractionVoteCounter) {
specialTypes.removeUndefined(item);
}
@@ -1003,9 +970,11 @@ public class RoomItemManager {
public int getUserUniqueFurniCount(int userId) {
THashSet<Item> items = new THashSet<>();
for (HabboItem item : this.roomItems.valueCollection()) {
if (!items.contains(item.getBaseItem()) && item.getUserId() == userId) {
items.add(item.getBaseItem());
synchronized (this.roomItems) {
for (HabboItem item : this.roomItems.valueCollection()) {
if (!items.contains(item.getBaseItem()) && item.getUserId() == userId) {
items.add(item.getBaseItem());
}
}
}
@@ -130,13 +130,16 @@ public class RoomLayout {
this.roomTiles = new RoomTile[this.mapSizeX][this.mapSizeY];
for (short y = 0; y < this.mapSizeY; y++) {
if (modelTemp[y].isEmpty() || modelTemp[y].equalsIgnoreCase("\r")) {
continue;
}
// A row shorter/longer than the model width (or empty) cannot be parsed
// per-square. Previously such tiles were left null while tileExists()
// still reported them present, causing NPEs in the coordinate accessors.
// Fill them with INVALID tiles so every in-bounds coordinate is non-null.
boolean validRow = !modelTemp[y].isEmpty() && modelTemp[y].length() == this.mapSizeX;
for (short x = 0; x < this.mapSizeX; x++) {
if (modelTemp[y].length() != this.mapSizeX) {
break;
if (!validRow) {
this.roomTiles[x][y] = new RoomTile(x, y, (short) 0, RoomTileState.INVALID, true);
continue;
}
String square = modelTemp[y].substring(x, x + 1).trim().toLowerCase();
@@ -159,7 +162,9 @@ public class RoomLayout {
}
}
this.doorTile = this.roomTiles[this.doorX][this.doorY];
this.doorTile = (this.doorX >= 0 && this.doorX < this.mapSizeX && this.doorY >= 0 && this.doorY < this.mapSizeY)
? this.roomTiles[this.doorX][this.doorY]
: null;
if (this.doorTile != null) {
this.doorTile.setAllowStack(false);
@@ -731,10 +731,10 @@ public class RoomManager {
habbo.getClient().sendResponse(new RoomModelComposer(room));
if (!room.getWallPaint().equals("0.0"))
if (room.getWallPaint() != null && !room.getWallPaint().equals("0.0"))
habbo.getClient().sendResponse(new RoomPaintComposer("wallpaper", room.getWallPaint()));
if (!room.getFloorPaint().equals("0.0"))
if (room.getFloorPaint() != null && !room.getFloorPaint().equals("0.0"))
habbo.getClient().sendResponse(new RoomPaintComposer("floor", room.getFloorPaint()));
habbo.getClient().sendResponse(new RoomPaintComposer("landscape", room.getBackgroundPaint()));
@@ -1020,6 +1020,10 @@ public class RoomManager {
room.getYoutubeWatchers()).compose());
}
habbo.getClient().sendResponse(new com.eu.habbo.messages.outgoing.soundboard.SoundboardSettingsComposer(
room.isSoundboardEnabled(),
Emulator.getGameEnvironment().getSoundboardManager().getSounds()).compose());
WiredManager.triggerUserEntersRoom(room, habbo.getRoomUnit());
room.habboEntered(habbo);
@@ -272,10 +272,16 @@ public class RoomRightsManager {
} else if (this.isOwner(habbo)) {
habbo.getClient().sendResponse(new RoomOwnerComposer());
flatCtrl = RoomRightLevels.MODERATOR;
} else if (this.hasRights(habbo) && !this.room.hasGuild()) {
flatCtrl = RoomRightLevels.RIGHTS;
} else if (this.room.hasGuild()) {
flatCtrl = this.getGuildRightLevel(habbo);
// Explicit room rights must still be honoured in guild rooms (the old
// `&& !hasGuild()` guard stripped them for non-guild members) take
// whichever of the two is stronger.
RoomRightLevels guildLevel = this.getGuildRightLevel(habbo);
flatCtrl = (this.hasRights(habbo) && RoomRightLevels.RIGHTS.isEqualOrGreaterThan(guildLevel))
? RoomRightLevels.RIGHTS
: guildLevel;
} else if (this.hasRights(habbo)) {
flatCtrl = RoomRightLevels.RIGHTS;
}
habbo.getClient().sendResponse(new RoomRightsComposer(flatCtrl));
@@ -71,6 +71,7 @@ public class RoomSpecialTypes {
private final THashMap<Integer, InteractionFreezeExitTile> freezeExitTile;
private final THashMap<Integer, HabboItem> undefined;
private final Set<ICycleable> cycleTasks;
private final ConcurrentHashMap<Integer, HabboItem> specialItemsById = new ConcurrentHashMap<>();
public RoomSpecialTypes() {
this.banzaiTeleporters = new THashMap<>(0);
@@ -115,11 +116,11 @@ public class RoomSpecialTypes {
}
public void addBanzaiTeleporter(InteractionBattleBanzaiTeleporter item) {
this.banzaiTeleporters.put(item.getId(), item);
this.banzaiTeleporters.put(item.getId(), item); this.specialItemsById.put(item.getId(), item);
}
public void removeBanzaiTeleporter(InteractionBattleBanzaiTeleporter item) {
this.banzaiTeleporters.remove(item.getId());
this.banzaiTeleporters.remove(item.getId()); this.specialItemsById.remove(item.getId());
}
public THashSet<InteractionBattleBanzaiTeleporter> getBanzaiTeleporters() {
@@ -151,15 +152,23 @@ public class RoomSpecialTypes {
public InteractionNest getNest(int itemId) {
return this.nests.get(itemId);
synchronized (this.nests) {
return this.nests.get(itemId);
}
}
public void addNest(InteractionNest item) {
this.nests.put(item.getId(), item);
synchronized (this.nests) {
this.nests.put(item.getId(), item);
}
this.specialItemsById.put(item.getId(), item);
}
public void removeNest(InteractionNest item) {
this.nests.remove(item.getId());
synchronized (this.nests) {
this.nests.remove(item.getId());
}
this.specialItemsById.remove(item.getId());
}
public THashSet<InteractionNest> getNests() {
@@ -173,15 +182,23 @@ public class RoomSpecialTypes {
public InteractionPetDrink getPetDrink(int itemId) {
return this.petDrinks.get(itemId);
synchronized (this.petDrinks) {
return this.petDrinks.get(itemId);
}
}
public void addPetDrink(InteractionPetDrink item) {
this.petDrinks.put(item.getId(), item);
synchronized (this.petDrinks) {
this.petDrinks.put(item.getId(), item);
}
this.specialItemsById.put(item.getId(), item);
}
public void removePetDrink(InteractionPetDrink item) {
this.petDrinks.remove(item.getId());
synchronized (this.petDrinks) {
this.petDrinks.remove(item.getId());
}
this.specialItemsById.remove(item.getId());
}
public THashSet<InteractionPetDrink> getPetDrinks() {
@@ -195,15 +212,23 @@ public class RoomSpecialTypes {
public InteractionPetFood getPetFood(int itemId) {
return this.petFoods.get(itemId);
synchronized (this.petFoods) {
return this.petFoods.get(itemId);
}
}
public void addPetFood(InteractionPetFood item) {
this.petFoods.put(item.getId(), item);
synchronized (this.petFoods) {
this.petFoods.put(item.getId(), item);
}
this.specialItemsById.put(item.getId(), item);
}
public void removePetFood(InteractionPetFood petFood) {
this.petFoods.remove(petFood.getId());
synchronized (this.petFoods) {
this.petFoods.remove(petFood.getId());
}
this.specialItemsById.remove(petFood.getId());
}
public THashSet<InteractionPetFood> getPetFoods() {
@@ -217,15 +242,23 @@ public class RoomSpecialTypes {
public InteractionPetToy getPetToy(int itemId) {
return this.petToys.get(itemId);
synchronized (this.petToys) {
return this.petToys.get(itemId);
}
}
public void addPetToy(InteractionPetToy item) {
this.petToys.put(item.getId(), item);
synchronized (this.petToys) {
this.petToys.put(item.getId(), item);
}
this.specialItemsById.put(item.getId(), item);
}
public void removePetToy(InteractionPetToy petToy) {
this.petToys.remove(petToy.getId());
synchronized (this.petToys) {
this.petToys.remove(petToy.getId());
}
this.specialItemsById.remove(petToy.getId());
}
public THashSet<InteractionPetToy> getPetToys() {
@@ -239,15 +272,23 @@ public class RoomSpecialTypes {
public InteractionPetTree getPetTree(int itemId) {
return this.petTrees.get(itemId);
synchronized (this.petTrees) {
return this.petTrees.get(itemId);
}
}
public void addPetTree(InteractionPetTree item) {
this.petTrees.put(item.getId(), item);
synchronized (this.petTrees) {
this.petTrees.put(item.getId(), item);
}
this.specialItemsById.put(item.getId(), item);
}
public void removePetTree(InteractionPetTree petTree) {
this.petTrees.remove(petTree.getId());
synchronized (this.petTrees) {
this.petTrees.remove(petTree.getId());
}
this.specialItemsById.remove(petTree.getId());
}
public THashSet<InteractionPetTree> getPetTrees() {
@@ -270,12 +311,14 @@ public class RoomSpecialTypes {
synchronized (this.rollers) {
this.rollers.put(item.getId(), item);
}
this.specialItemsById.put(item.getId(), item);
}
public void removeRoller(InteractionRoller roller) {
synchronized (this.rollers) {
this.rollers.remove(roller.getId());
}
this.specialItemsById.remove(roller.getId());
}
public THashMap<Integer, InteractionRoller> getRollers() {
@@ -469,11 +512,11 @@ public class RoomSpecialTypes {
// Add to type-based index
this.wiredTriggers.computeIfAbsent(trigger.getType(), k -> ConcurrentHashMap.newKeySet())
.add(trigger);
// Add to spatial index
long key = coordinateKey(trigger.getX(), trigger.getY());
this.wiredTriggersByLocation.computeIfAbsent(key, k -> ConcurrentHashMap.newKeySet())
.add(trigger);
this.specialItemsById.put(trigger.getId(), trigger);
}
/**
@@ -489,7 +532,6 @@ public class RoomSpecialTypes {
this.wiredTriggers.remove(trigger.getType());
}
}
// Remove from spatial index
long key = coordinateKey(trigger.getX(), trigger.getY());
Set<InteractionWiredTrigger> locationTriggers = this.wiredTriggersByLocation.get(key);
@@ -499,6 +541,7 @@ public class RoomSpecialTypes {
this.wiredTriggersByLocation.remove(key);
}
}
this.specialItemsById.remove(trigger.getId());
}
/**
@@ -589,11 +632,11 @@ public class RoomSpecialTypes {
// Add to type-based index
this.wiredEffects.computeIfAbsent(effect.getType(), k -> ConcurrentHashMap.newKeySet())
.add(effect);
// Add to spatial index
long key = coordinateKey(effect.getX(), effect.getY());
this.wiredEffectsByLocation.computeIfAbsent(key, k -> ConcurrentHashMap.newKeySet())
.add(effect);
this.specialItemsById.put(effect.getId(), effect);
}
/**
@@ -609,7 +652,6 @@ public class RoomSpecialTypes {
this.wiredEffects.remove(effect.getType());
}
}
// Remove from spatial index
long key = coordinateKey(effect.getX(), effect.getY());
Set<InteractionWiredEffect> locationEffects = this.wiredEffectsByLocation.get(key);
@@ -619,6 +661,7 @@ public class RoomSpecialTypes {
this.wiredEffectsByLocation.remove(key);
}
}
this.specialItemsById.remove(effect.getId());
}
/**
@@ -709,11 +752,11 @@ public class RoomSpecialTypes {
// Add to type-based index
this.wiredConditions.computeIfAbsent(condition.getType(), k -> ConcurrentHashMap.newKeySet())
.add(condition);
// Add to spatial index
long key = coordinateKey(condition.getX(), condition.getY());
this.wiredConditionsByLocation.computeIfAbsent(key, k -> ConcurrentHashMap.newKeySet())
.add(condition);
this.specialItemsById.put(condition.getId(), condition);
}
/**
@@ -729,7 +772,6 @@ public class RoomSpecialTypes {
this.wiredConditions.remove(condition.getType());
}
}
// Remove from spatial index
long key = coordinateKey(condition.getX(), condition.getY());
Set<InteractionWiredCondition> locationConditions = this.wiredConditionsByLocation.get(key);
@@ -739,6 +781,7 @@ public class RoomSpecialTypes {
this.wiredConditionsByLocation.remove(key);
}
}
this.specialItemsById.remove(condition.getId());
}
/**
@@ -805,11 +848,11 @@ public class RoomSpecialTypes {
*/
public void addExtra(InteractionWiredExtra extra) {
this.wiredExtras.put(extra.getId(), extra);
// Add to spatial index
long key = coordinateKey(extra.getX(), extra.getY());
this.wiredExtrasByLocation.computeIfAbsent(key, k -> ConcurrentHashMap.newKeySet())
.add(extra);
this.specialItemsById.put(extra.getId(), extra);
}
/**
@@ -818,7 +861,6 @@ public class RoomSpecialTypes {
*/
public void removeExtra(InteractionWiredExtra extra) {
this.wiredExtras.remove(extra.getId());
// Remove from spatial index
long key = coordinateKey(extra.getX(), extra.getY());
Set<InteractionWiredExtra> locationExtras = this.wiredExtrasByLocation.get(key);
@@ -828,6 +870,7 @@ public class RoomSpecialTypes {
this.wiredExtrasByLocation.remove(key);
}
}
this.specialItemsById.remove(extra.getId());
}
/**
@@ -880,11 +923,11 @@ public class RoomSpecialTypes {
}
public void addGameScoreboard(InteractionGameScoreboard scoreboard) {
this.gameScoreboards.put(scoreboard.getId(), scoreboard);
this.gameScoreboards.put(scoreboard.getId(), scoreboard); this.specialItemsById.put(scoreboard.getId(), scoreboard);
}
public void removeScoreboard(InteractionGameScoreboard scoreboard) {
this.gameScoreboards.remove(scoreboard.getId());
this.gameScoreboards.remove(scoreboard.getId()); this.specialItemsById.remove(scoreboard.getId());
}
public THashMap<Integer, InteractionFreezeScoreboard> getFreezeScoreboards() {
@@ -980,11 +1023,11 @@ public class RoomSpecialTypes {
}
public void addGameGate(InteractionGameGate gameGate) {
this.gameGates.put(gameGate.getId(), gameGate);
this.gameGates.put(gameGate.getId(), gameGate); this.specialItemsById.put(gameGate.getId(), gameGate);
}
public void removeGameGate(InteractionGameGate gameGate) {
this.gameGates.remove(gameGate.getId());
this.gameGates.remove(gameGate.getId()); this.specialItemsById.remove(gameGate.getId());
}
public THashMap<Integer, InteractionFreezeGate> getFreezeGates() {
@@ -1021,11 +1064,11 @@ public class RoomSpecialTypes {
}
public void addGameTimer(InteractionGameTimer gameTimer) {
this.gameTimers.put(gameTimer.getId(), gameTimer);
this.gameTimers.put(gameTimer.getId(), gameTimer); this.specialItemsById.put(gameTimer.getId(), gameTimer);
}
public void removeGameTimer(InteractionGameTimer gameTimer) {
this.gameTimers.remove(gameTimer.getId());
this.gameTimers.remove(gameTimer.getId()); this.specialItemsById.remove(gameTimer.getId());
}
public THashMap<Integer, InteractionGameTimer> getGameTimers() {
@@ -1043,7 +1086,7 @@ public class RoomSpecialTypes {
}
public void addFreezeExitTile(InteractionFreezeExitTile freezeExitTile) {
this.freezeExitTile.put(freezeExitTile.getId(), freezeExitTile);
this.freezeExitTile.put(freezeExitTile.getId(), freezeExitTile); this.specialItemsById.put(freezeExitTile.getId(), freezeExitTile);
}
public THashMap<Integer, InteractionFreezeExitTile> getFreezeExitTiles() {
@@ -1051,7 +1094,7 @@ public class RoomSpecialTypes {
}
public void removeFreezeExitTile(InteractionFreezeExitTile freezeExitTile) {
this.freezeExitTile.remove(freezeExitTile.getId());
this.freezeExitTile.remove(freezeExitTile.getId()); this.specialItemsById.remove(freezeExitTile.getId());
}
public boolean hasFreezeExitTile() {
@@ -1062,12 +1105,14 @@ public class RoomSpecialTypes {
synchronized (this.undefined) {
this.undefined.put(item.getId(), item);
}
this.specialItemsById.put(item.getId(), item);
}
public void removeUndefined(HabboItem item) {
synchronized (this.undefined) {
this.undefined.remove(item.getId());
}
this.specialItemsById.remove(item.getId());
}
public THashSet<HabboItem> getItemsOfType(Class<? extends HabboItem> type) {
@@ -1130,6 +1175,10 @@ public class RoomSpecialTypes {
this.cycleTasks.remove(task);
}
public HabboItem getSpecialItem(int itemId) {
return this.specialItemsById.get(itemId);
}
public synchronized void dispose() {
this.banzaiTeleporters.clear();
this.nests.clear();
@@ -1142,6 +1191,7 @@ public class RoomSpecialTypes {
this.wiredTriggers.clear();
this.wiredEffects.clear();
this.wiredConditions.clear();
this.wiredExtras.clear();
this.gameScoreboards.clear();
this.gameGates.clear();
@@ -1150,6 +1200,7 @@ public class RoomSpecialTypes {
this.freezeExitTile.clear();
this.undefined.clear();
this.cycleTasks.clear();
this.specialItemsById.clear();
}
public Rectangle tentAt(RoomTile location) {
@@ -29,7 +29,6 @@ public class RoomTileManager {
*/
public void updateTile(RoomTile tile) {
if (tile != null) {
this.room.tileCache.remove(tile);
this.room.getItemManager().tileCache.remove(tile);
tile.setStackHeight(this.getStackHeight(tile.x, tile.y, false));
tile.setState(this.calculateTileState(tile));
@@ -41,7 +40,6 @@ public class RoomTileManager {
*/
public void updateTiles(THashSet<RoomTile> tiles) {
for (RoomTile tile : tiles) {
this.room.tileCache.remove(tile);
this.room.getItemManager().tileCache.remove(tile);
tile.setStackHeight(this.getStackHeight(tile.x, tile.y, false));
tile.setState(this.calculateTileState(tile));
@@ -26,6 +26,7 @@ public class RoomTrade {
private final List<RoomTradeUser> users;
private final Room room;
private boolean completed = false;
public RoomTrade(Habbo userOne, Habbo userTwo, Room room) {
this.users = new ArrayList<>();
@@ -54,7 +55,7 @@ public class RoomTrade {
this.sendMessageToUsers(new TradeStartComposer(this));
}
public void offerItem(Habbo habbo, HabboItem item) {
public synchronized void offerItem(Habbo habbo, HabboItem item) {
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
if (user.getItems().contains(item))
@@ -67,7 +68,7 @@ public class RoomTrade {
this.updateWindow();
}
public void offerMultipleItems(Habbo habbo, THashSet<HabboItem> items) {
public synchronized void offerMultipleItems(Habbo habbo, THashSet<HabboItem> items) {
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
for (HabboItem item : items) {
@@ -81,7 +82,7 @@ public class RoomTrade {
this.updateWindow();
}
public void removeItem(Habbo habbo, HabboItem item) {
public synchronized void removeItem(Habbo habbo, HabboItem item) {
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
if (!user.getItems().contains(item))
@@ -94,7 +95,7 @@ public class RoomTrade {
this.updateWindow();
}
public void accept(Habbo habbo, boolean value) {
public synchronized void accept(Habbo habbo, boolean value) {
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
user.setAccepted(value);
@@ -110,7 +111,13 @@ public class RoomTrade {
}
}
public void confirm(Habbo habbo) {
public synchronized void confirm(Habbo habbo) {
// Re-entry guard: both participants confirm on their own EventLoop
// threads. Without this (and the method-level lock) two concurrent
// confirms could each observe "all confirmed" and run tradeItems()
// twice item/credit duplication.
if (this.completed) return;
RoomTradeUser user = this.getRoomTradeUserForHabbo(habbo);
user.confirm();
@@ -122,6 +129,8 @@ public class RoomTrade {
accepted = false;
}
if (accepted) {
this.completed = true;
if (this.tradeItems()) {
this.closeWindow();
this.sendMessageToUsers(new TradeCompleteComposer());
@@ -264,6 +273,10 @@ public class RoomTrade {
protected void clearAccepted() {
for (RoomTradeUser user : this.users) {
user.setAccepted(false);
// Any change to the offered items invalidates a prior confirmation;
// without this a stale confirmed=true lets a user strip their side
// and still complete the trade once the partner re-confirms.
user.setConfirmed(false);
}
}
@@ -51,6 +51,10 @@ public class RoomTradeUser {
this.confirmed = true;
}
public void setConfirmed(boolean value) {
this.confirmed = value;
}
public void addItem(HabboItem item) {
this.items.add(item);
}
@@ -31,6 +31,7 @@ import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.ScheduledFuture;
import java.util.stream.Collectors;
@@ -71,7 +72,10 @@ public class RoomUnit {
private RoomUserRotation headRotation = RoomUserRotation.NORTH;
private DanceType danceType;
private RoomUnitType roomUnitType;
private Deque<RoomTile> path = new LinkedList<>();
// Concurrent + volatile: the room cycle thread polls/clears this path while a
// walk packet thread rebuilds it via findPath/setPath. A plain LinkedList would
// corrupt under the concurrent structural modification.
private volatile Deque<RoomTile> path = new ConcurrentLinkedDeque<>();
private int handItem;
private long handItemTimestamp;
private long lastRollerTime;
@@ -587,7 +591,7 @@ public class RoomUnit {
Deque<RoomTile> newPath = this.room.getLayout().getPathfinder()
.findPath(this.currentLocation, this.goalLocation, this.goalLocation, this);
if (newPath != null && !newPath.isEmpty()) {
this.path = newPath;
this.path = new ConcurrentLinkedDeque<>(newPath);
}
}
@@ -765,7 +769,7 @@ public class RoomUnit {
}
public void setPath(Deque<RoomTile> path) {
this.path = path;
this.path = (path == null) ? new ConcurrentLinkedDeque<>() : new ConcurrentLinkedDeque<>(path);
}
public RoomRightLevels getRightsLevel() {
@@ -71,6 +71,24 @@ public class RoomUnitManager {
*/
public void clear() {
synchronized (this.room.roomUnitLock) {
for (Habbo habbo : this.currentHabbos.values()) {
if (habbo.getRoomUnit() != null) {
WiredMoveCarryHelper.cleanupRoomUnit(habbo.getRoomUnit());
WiredUserMovementHelper.cleanupRoomUnit(habbo.getRoomUnit());
}
}
for (Bot bot : this.currentBots.valueCollection()) {
if (bot.getRoomUnit() != null) {
WiredMoveCarryHelper.cleanupRoomUnit(bot.getRoomUnit());
WiredUserMovementHelper.cleanupRoomUnit(bot.getRoomUnit());
}
}
for (Pet pet : this.currentPets.valueCollection()) {
if (pet.getRoomUnit() != null) {
WiredMoveCarryHelper.cleanupRoomUnit(pet.getRoomUnit());
WiredUserMovementHelper.cleanupRoomUnit(pet.getRoomUnit());
}
}
this.unitCounter = 0;
this.currentHabbos.clear();
this.currentPets.clear();
@@ -222,6 +240,8 @@ public class RoomUnitManager {
}
if (habbo.getRoomUnit() != null) {
WiredMoveCarryHelper.cleanupRoomUnit(habbo.getRoomUnit());
WiredUserMovementHelper.cleanupRoomUnit(habbo.getRoomUnit());
WiredManager.triggerUserLeavesRoom(this.room, habbo.getRoomUnit());
if (WiredFreezeUtil.isFrozen(habbo.getRoomUnit())) {
WiredFreezeUtil.unfreeze(this.room, habbo.getRoomUnit());
@@ -646,14 +666,22 @@ public class RoomUnitManager {
public boolean removeBot(Bot bot) {
synchronized (this.currentBots) {
if (this.currentBots.containsKey(bot.getId())) {
if (bot.getRoomUnit() != null && bot.getRoomUnit().getCurrentLocation() != null) {
bot.getRoomUnit().getCurrentLocation().removeUnit(bot.getRoomUnit());
if (bot.getRoomUnit() != null) {
WiredMoveCarryHelper.cleanupRoomUnit(bot.getRoomUnit());
WiredUserMovementHelper.cleanupRoomUnit(bot.getRoomUnit());
if (bot.getRoomUnit().getCurrentLocation() != null) {
bot.getRoomUnit().getCurrentLocation().removeUnit(bot.getRoomUnit());
}
}
this.currentBots.remove(bot.getId());
bot.getRoomUnit().setInRoom(false);
if (bot.getRoomUnit() != null) {
bot.getRoomUnit().setInRoom(false);
}
bot.setRoom(null);
this.room.sendComposer(new RoomUserRemoveComposer(bot.getRoomUnit()).compose());
if (bot.getRoomUnit() != null) {
this.room.sendComposer(new RoomUserRemoveComposer(bot.getRoomUnit()).compose());
}
bot.setRoomUnit(null);
return true;
}
@@ -876,7 +904,12 @@ public class RoomUnitManager {
* Removes a Pet from the room.
*/
public Pet removePet(int petId) {
return this.currentPets.remove(petId);
Pet pet = this.currentPets.remove(petId);
if (pet != null && pet.getRoomUnit() != null) {
WiredMoveCarryHelper.cleanupRoomUnit(pet.getRoomUnit());
WiredUserMovementHelper.cleanupRoomUnit(pet.getRoomUnit());
}
return pet;
}
/**
@@ -1454,6 +1487,24 @@ public class RoomUnitManager {
}
public void dispose() {
for (Habbo habbo : this.currentHabbos.values()) {
if (habbo.getRoomUnit() != null) {
WiredMoveCarryHelper.cleanupRoomUnit(habbo.getRoomUnit());
WiredUserMovementHelper.cleanupRoomUnit(habbo.getRoomUnit());
}
}
for (Bot bot : this.currentBots.valueCollection()) {
if (bot.getRoomUnit() != null) {
WiredMoveCarryHelper.cleanupRoomUnit(bot.getRoomUnit());
WiredUserMovementHelper.cleanupRoomUnit(bot.getRoomUnit());
}
}
for (Pet pet : this.currentPets.valueCollection()) {
if (pet.getRoomUnit() != null) {
WiredMoveCarryHelper.cleanupRoomUnit(pet.getRoomUnit());
WiredUserMovementHelper.cleanupRoomUnit(pet.getRoomUnit());
}
}
this.currentHabbos.clear();
this.currentBots.clear();
this.currentPets.clear();
@@ -35,6 +35,7 @@ public class RoomUserVariableManager {
private final Room room;
private final ConcurrentHashMap<Integer, ConcurrentHashMap<Integer, VariableAssignment>> activeAssignmentsByUserId;
private final java.util.concurrent.atomic.AtomicBoolean broadcastRequested = new java.util.concurrent.atomic.AtomicBoolean(false);
public RoomUserVariableManager(Room room) {
this.room = room;
@@ -660,7 +661,22 @@ public class RoomUserVariableManager {
habbo.getClient().sendResponse(new WiredUserVariablesDataComposer(this.createSnapshot(), this.room.getFurniVariableManager().createSnapshot(), this.room.getRoomVariableManager().createSnapshot()));
}
public void requestBroadcast() {
if (this.broadcastRequested.compareAndSet(false, true)) {
Emulator.getThreading().run(() -> {
this.broadcastRequested.set(false);
if (this.room.isLoaded()) {
this.broadcastSnapshotRaw();
}
}, 50);
}
}
public void broadcastSnapshot() {
this.requestBroadcast();
}
public void broadcastSnapshotRaw() {
Snapshot userSnapshot = this.createSnapshot();
RoomFurniVariableManager.Snapshot furniSnapshot = this.room.getFurniVariableManager().createSnapshot();
RoomVariableManager.Snapshot roomSnapshot = this.room.getRoomVariableManager().createSnapshot();
@@ -35,6 +35,7 @@ public class RoomVariableManager {
private final Room room;
private final ConcurrentHashMap<Integer, VariableAssignment> activeAssignmentsByDefinitionId;
private volatile boolean persistentValuesLoaded;
private final java.util.concurrent.atomic.AtomicBoolean broadcastRequested = new java.util.concurrent.atomic.AtomicBoolean(false);
public RoomVariableManager(Room room) {
this.room = room;
@@ -433,7 +434,22 @@ public class RoomVariableManager {
habbo.getClient().sendResponse(new WiredUserVariablesDataComposer(this.room.getUserVariableManager().createSnapshot(), this.room.getFurniVariableManager().createSnapshot(), this.createSnapshot()));
}
public void requestBroadcast() {
if (this.broadcastRequested.compareAndSet(false, true)) {
Emulator.getThreading().run(() -> {
this.broadcastRequested.set(false);
if (this.room.isLoaded()) {
this.broadcastSnapshotRaw();
}
}, 50);
}
}
public void broadcastSnapshot() {
this.requestBroadcast();
}
public void broadcastSnapshotRaw() {
RoomUserVariableManager.Snapshot userSnapshot = this.room.getUserVariableManager().createSnapshot();
RoomFurniVariableManager.Snapshot furniSnapshot = this.room.getFurniVariableManager().createSnapshot();
Snapshot roomSnapshot = this.createSnapshot();
@@ -24,8 +24,11 @@ public class PathfinderImpl implements Pathfinder {
private static final int CACHED_TIMEOUT_MS = Emulator.getConfig()
.getInt(CONFIG_EXECUTION_TIME, 25);
// Default ON: bound A* to CACHED_TIMEOUT_MS (25ms) so a pathological search
// can't run unbounded and stall the thread. On timeout findPath returns an
// empty path (the unit simply doesn't move there) graceful degradation.
private static final boolean CACHED_TIMEOUT_ENABLED = Emulator.getConfig()
.getBoolean(CONFIG_TIMEOUT_ENABLED, false);
.getBoolean(CONFIG_TIMEOUT_ENABLED, true);
private static final long CACHED_TIMEOUT_NANOS = CACHED_TIMEOUT_MS * 1_000_000L;
private final Room room;
@@ -0,0 +1,78 @@
package com.eu.habbo.habbohotel.soundboard;
import com.eu.habbo.Emulator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
public class SoundboardManager {
private static final Logger LOGGER = LoggerFactory.getLogger(SoundboardManager.class);
private final List<SoundboardSound> sounds = new ArrayList<>();
public SoundboardManager() {
long millis = System.currentTimeMillis();
this.bootstrap();
this.reload();
LOGGER.info("Soundboard Manager -> Loaded! ({} MS, {} sounds)", System.currentTimeMillis() - millis, this.sounds.size());
}
// Self-bootstrap: room flag column + sounds table, so the feature works even
// before the manual migration is applied.
private void bootstrap() {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
Statement statement = connection.createStatement()) {
statement.execute("ALTER TABLE `rooms` ADD COLUMN IF NOT EXISTS `soundboard_enabled` TINYINT(1) NOT NULL DEFAULT 0");
statement.execute("CREATE TABLE IF NOT EXISTS `soundboard_sounds` (" +
"`id` INT(11) NOT NULL AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL DEFAULT '', " +
"`url` VARCHAR(255) NOT NULL DEFAULT '', `enabled` TINYINT(1) NOT NULL DEFAULT 1, " +
"`sort_order` INT(11) NOT NULL DEFAULT 0, PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
} catch (SQLException e) {
LOGGER.error("Failed to bootstrap soundboard schema", e);
}
}
public void reload() {
this.sounds.clear();
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT id, name, url FROM soundboard_sounds WHERE enabled = 1 ORDER BY sort_order ASC, id ASC");
ResultSet set = statement.executeQuery()) {
while (set.next()) {
this.sounds.add(new SoundboardSound(set));
}
} catch (SQLException e) {
LOGGER.error("Failed to load soundboard sounds", e);
}
}
public List<SoundboardSound> getSounds() {
return this.sounds;
}
public SoundboardSound getSound(int id) {
for (SoundboardSound sound : this.sounds) {
if (sound.id == id) return sound;
}
return null;
}
// Owner toggle persists the room flag with a dedicated UPDATE (kept out of
// the big room-settings save to avoid touching that statement).
public void setRoomEnabled(int roomId, boolean enabled) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("UPDATE rooms SET soundboard_enabled = ? WHERE id = ? LIMIT 1")) {
statement.setString(1, enabled ? "1" : "0");
statement.setInt(2, roomId);
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Failed to set soundboard_enabled for room {}", roomId, e);
}
}
}
@@ -0,0 +1,17 @@
package com.eu.habbo.habbohotel.soundboard;
import java.sql.ResultSet;
import java.sql.SQLException;
// One soundboard pad: a named audio clip served from a URL (uploaded via the CMS).
public class SoundboardSound {
public final int id;
public final String name;
public final String url;
public SoundboardSound(ResultSet set) throws SQLException {
this.id = set.getInt("id");
this.name = set.getString("name");
this.url = set.getString("url");
}
}
@@ -146,31 +146,23 @@ public class Habbo implements Runnable {
this.habboInfo.setIpLogin(ip);
}
if (this.client.getMachineId() == null || this.client.getMachineId().length() == 0) {
return false;
}
// The Nitro client sends the UniqueID (machine fingerprint) packet right
// AFTER the SSO ticket, so client.getMachineId() may still be null here.
// Do NOT reject the login for a missing machineId MachineIDEvent sets it
// and enforces the MAC ban as soon as the UniqueID packet arrives. Only
// MAC-ban check here when the fingerprint is already available.
String machineId = this.client.getMachineId();
if (machineId != null && !machineId.isEmpty()) {
this.habboInfo.setMachineID(machineId);
this.habboInfo.setMachineID(this.client.getMachineId());
if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) {
return false;
if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) {
return false;
}
}
if (Emulator.getGameEnvironment().getModToolManager().hasIPBan(this.habboInfo.getIpLogin())) {
return false;
}
this.habboInfo.setMachineID(this.client.getMachineId());
if (Emulator.getGameEnvironment().getModToolManager().hasMACBan(this.client)) {
return false;
}
if (Emulator.getGameEnvironment().getModToolManager().hasIPBan(this.habboInfo.getIpLogin())) {
return false;
}
this.habboInfo.setMachineID(this.client.getMachineId());
this.isOnline(true);
this.messenger.connectionChanged(this, true, false);
@@ -55,6 +55,11 @@ public class HabboInfo implements Runnable {
private RideablePet riding;
private Class<? extends Game> currentGame;
private TIntIntHashMap currencies;
// Serializes credits + currencies read-modify-write and the saveCurrencies
// snapshot so the credit-roller thread and purchase/trade handler threads
// can't lose updates or rehash the Trove map mid-iteration. Never held
// across run()'s DB I/O.
private final Object currencyLock = new Object();
private GamePlayer gamePlayer;
private int photoRoomId;
private int photoTimestamp;
@@ -123,11 +128,16 @@ public class HabboInfo implements Runnable {
}
private void saveCurrencies() {
List<int[]> entries = new ArrayList<>(this.currencies.size());
this.currencies.forEachEntry((type, amount) -> {
entries.add(new int[]{type, amount});
return true;
});
// Snapshot under the lock so a concurrent adjustOrPutValue/put can't
// rehash the Trove map while we iterate; do the DB batch off-lock.
List<int[]> entries;
synchronized (this.currencyLock) {
entries = new ArrayList<>(this.currencies.size());
this.currencies.forEachEntry((type, amount) -> {
entries.add(new int[]{type, amount});
return true;
});
}
try {
SqlQueries.batchUpdate(
@@ -238,20 +248,30 @@ public class HabboInfo implements Runnable {
}
public int getCurrencyAmount(int type) {
return this.currencies.get(type);
synchronized (this.currencyLock) {
return this.currencies.get(type);
}
}
public TIntIntHashMap getCurrencies() {
return this.currencies;
// Return a snapshot under the lock: callers iterate this map, which would
// otherwise corrupt during a concurrent adjustOrPutValue rehash.
synchronized (this.currencyLock) {
return new TIntIntHashMap(this.currencies);
}
}
public void addCurrencyAmount(int type, int amount) {
this.currencies.adjustOrPutValue(type, amount, amount);
synchronized (this.currencyLock) {
this.currencies.adjustOrPutValue(type, amount, amount);
}
this.run();
}
public void setCurrencyAmount(int type, int amount) {
this.currencies.put(type, amount);
synchronized (this.currencyLock) {
this.currencies.put(type, amount);
}
this.run();
}
@@ -380,20 +400,26 @@ public class HabboInfo implements Runnable {
}
public boolean canBuy(CatalogItem item) {
return this.credits >= item.getCredits() && this.getCurrencies().get(item.getPointsType()) >= item.getPoints();
return this.getCredits() >= item.getCredits() && this.getCurrencyAmount(item.getPointsType()) >= item.getPoints();
}
public int getCredits() {
return this.credits;
synchronized (this.currencyLock) {
return this.credits;
}
}
public void setCredits(int credits) {
this.credits = credits;
synchronized (this.currencyLock) {
this.credits = credits;
}
this.run();
}
public void addCredits(int credits) {
this.credits += credits;
synchronized (this.currencyLock) {
this.credits += credits;
}
this.run();
}
@@ -600,6 +626,13 @@ public class HabboInfo implements Runnable {
public void run() {
this.saveCurrencies();
// Read credits under the lock so the persisted value is consistent with
// concurrent addCredits/setCredits (matches the currencyLock invariant).
final int creditsForSave;
synchronized (this.currencyLock) {
creditsForSave = this.credits;
}
try {
SqlQueries.update(
"UPDATE users SET motto = ?, online = ?, look = ?, gender = ?, credits = ?, last_login = ?, last_online = ?, home_room = ?, ip_current = ?, `rank` = ?, machine_id = ?, username = ?, background_id = ?, background_stand_id = ?, background_overlay_id = ?, background_card_id = ?, background_border_id = ? WHERE id = ?",
@@ -607,7 +640,7 @@ public class HabboInfo implements Runnable {
this.online ? "1" : "0",
this.look,
this.gender.name(),
this.credits,
creditsForSave,
Emulator.getIntUnixTimestamp(),
this.lastOnline,
this.homeRoom,
@@ -38,6 +38,7 @@ public class HabboManager {
private final ConcurrentHashMap<Integer, Habbo> onlineHabbos;
private final ConcurrentHashMap<String, Habbo> onlineHabbosByName;
private final ConcurrentHashMap<Integer, String> usernameCache = new ConcurrentHashMap<>();
public HabboManager() {
long millis = System.currentTimeMillis();
@@ -110,7 +111,7 @@ public class HabboManager {
habbo = this.cloneCheck(userId);
if (habbo != null) {
habbo.alert(Emulator.getTexts().getValue("loggedin.elsewhere"));
Emulator.getGameServer().getGameClientManager().disposeClient(habbo.getClient());
Emulator.getGameServer().getGameClientManager().forceDisposeClient(habbo.getClient());
habbo = null;
}
@@ -131,15 +132,12 @@ public class HabboManager {
Emulator.getPluginManager().fireEvent(new UserRegisteredEvent(habbo));
}
if (!Emulator.debugging) {
try (PreparedStatement stmt = connection.prepareStatement("UPDATE users SET auth_ticket = ? WHERE id = ? LIMIT 1")) {
stmt.setString(1, "");
stmt.setInt(2, habbo.getHabboInfo().getId());
stmt.execute();
} catch (SQLException e) {
LOGGER.error("Caught SQL exception", e);
}
}
// NB: il ticket SSO NON viene svuotato qui di proposito. Dietro
// Cloudflare il WebSocket viene droppato e il client ritenta più
// volte con lo STESSO ticket: se lo consumassimo al primo uso, i
// retry (e l'hard-refresh) fallirebbero con "non-existing SSO token".
// Il ticket resta valido fino alla scadenza (auth_ticket_expires_at,
// TTL gestito dal CMS) o finché il CMS non ne scrive uno nuovo / logout.
}
}
} catch (SQLException e) {
@@ -158,6 +156,26 @@ public class HabboManager {
return this.getHabbo(id).getHabboInfo();
}
public String getCachedUsername(int id) {
String cached = this.usernameCache.get(id);
if (cached != null) return cached;
Habbo online = this.getHabbo(id);
if (online != null) {
String name = online.getHabboInfo().getUsername();
this.usernameCache.put(id, name);
return name;
}
HabboInfo offline = getOfflineHabboInfo(id);
if (offline != null) {
String name = offline.getUsername();
this.usernameCache.put(id, name);
return name;
}
return "Unknown";
}
public int getOnlineCount() {
return this.onlineHabbos.size();
}
@@ -89,10 +89,13 @@ public class HabboStats implements Runnable {
public long lastTradeTimestamp = Emulator.getIntUnixTimestamp();
public long lastGiftTimestamp = Emulator.getIntUnixTimestamp();
public long lastPurchaseTimestamp = Emulator.getIntUnixTimestamp();
public long lastFloorplanSaveTimestamp = 0;
public int uiFlags;
public boolean hasGottenDefaultSavedSearches;
private HabboInfo habboInfo;
private boolean allowTrade;
private boolean mentionsEnabled;
private boolean massMentionsEnabled;
private int clubExpireTimestamp;
private int muteEndTime;
public int maxFriends;
@@ -130,6 +133,8 @@ public class HabboStats implements Runnable {
this.guilds = new ArrayList<>();
this.tags = set.getString("tags").split(";");
this.allowTrade = set.getString("can_trade").equals("1");
this.mentionsEnabled = "1".equals(safeColumnString(set, "mentions_enabled", "1"));
this.massMentionsEnabled = "1".equals(safeColumnString(set, "mass_mentions_enabled", "1"));
this.votedRooms = new TIntArrayStack();
this.clubExpireTimestamp = set.getInt("club_expire_timestamp");
this.loginStreak = set.getInt("login_streak");
@@ -443,14 +448,26 @@ public class HabboStats implements Runnable {
return 0;
}
if (this.achievementProgress.containsKey(achievement))
return this.achievementProgress.get(achievement);
return -1;
synchronized (this.achievementProgress) {
Integer progress = this.achievementProgress.get(achievement);
return progress != null ? progress : -1;
}
}
public void setProgress(Achievement achievement, int progress) {
this.achievementProgress.put(achievement, progress);
synchronized (this.achievementProgress) {
this.achievementProgress.put(achievement, progress);
}
}
/** Atomic read-add-write so concurrent progress sources don't lose updates. Returns the new total. */
public int incrementProgress(Achievement achievement, int amount) {
synchronized (this.achievementProgress) {
Integer current = this.achievementProgress.get(achievement);
int next = (current != null ? current : 0) + amount;
this.achievementProgress.put(achievement, next);
return next;
}
}
public int getRentedTimeEnd() {
@@ -748,13 +765,6 @@ public class HabboStats implements Runnable {
return 0;
}
/**
* Ignore an user.
*
* @param gameClient The client to which this HabboStats instance belongs.
* @param userId The user to ignore.
* @return true if successfully ignored, false otherwise.
*/
public boolean ignoreUser(GameClient gameClient, int userId) {
final Habbo target = Emulator.getGameEnvironment().getHabboManager().getHabbo(userId);
@@ -804,6 +814,44 @@ public class HabboStats implements Runnable {
else return this.allowTrade;
}
public boolean mentionsEnabled() {
return this.mentionsEnabled;
}
public boolean massMentionsEnabled() {
return this.massMentionsEnabled;
}
public void setMentionsEnabled(boolean enabled) {
this.mentionsEnabled = enabled;
persistFlag("mentions_enabled", enabled);
}
public void setMassMentionsEnabled(boolean enabled) {
this.massMentionsEnabled = enabled;
persistFlag("mass_mentions_enabled", enabled);
}
private void persistFlag(String column, boolean enabled) {
try (Connection connection = Emulator.getDatabase().getDataSource().getConnection();
PreparedStatement statement = connection.prepareStatement("UPDATE users_settings SET `" + column + "` = ? WHERE user_id = ? LIMIT 1")) {
statement.setString(1, enabled ? "1" : "0");
statement.setInt(2, this.habboInfo.getId());
statement.executeUpdate();
} catch (SQLException e) {
LOGGER.error("Failed to persist users_settings.{} for user {}", column, this.habboInfo.getId(), e);
}
}
private static String safeColumnString(ResultSet set, String column, String defaultValue) {
try {
String value = set.getString(column);
return value == null ? defaultValue : value;
} catch (SQLException e) {
return defaultValue;
}
}
public void setAllowTrade(boolean allowTrade) {
this.allowTrade = allowTrade;
}

Some files were not shown because too many files have changed in this diff Show More