Compare commits

...

110 Commits

Author SHA1 Message Date
Aaron Kimbrell
9e006a0e35 Merge remote-tracking branch 'origin/main' into web-dashboard-simplier
# Conflicts:
#	dMasterServer/MasterServer.cpp
2026-06-28 01:57:53 -05:00
David Markowitz
35337291fa feat: debugger additions (#2013)
* feat: debugger additions

Add type field for links in flash
Add warning level for dangerous buttons
fix uninitialzied memory with jetpack variable
remove a bunch of duplicated position push code

tested that the ui is still functional and components with multiple physics components have all their details visible.
tested that jetpack is initialized now

* remove amf3 header

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fixes

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-24 17:18:16 -05:00
Aaron Kimbrell
5d523a1e7b fix: handling of the same skill on multiple items (#1990)
* refactor: update behavior slot determination to use equipLocation instead of itemType

* fix: improve skill management in InventoryComponent to ensure correct client updates
2026-06-22 17:16:57 -07:00
David Markowitz
5745742c91 feat: dragon instance script (#2012)
* feat: dragon instance script

* Update FvDragonInstanceServer.h

* feat: implement ronin script

* Update CountdownDestroyAI.cpp

* default initialize

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* remove unused handlers

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* use float

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fixes

* Update ScriptComponent.h

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-22 17:16:21 -07:00
Aaron Kimbrell
62f58f5307 chore: cleanup possession handling (#1984)
* feat: enhance possession mechanics with skill set integration and improved message handling

* fix: restore SetPossessor in Mount() and scope IsRacing to vehicles

SetPossessor was missing from Mount(), breaking direct possessions via
PossessableComponent::OnUse which bypasses HandlePossession. IsRacing
now only set/cleared when the mount has HavokVehiclePhysicsComponent,
preventing non-vehicle possessions from incorrectly affecting the
distance-driven statistic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-21 21:39:51 -07:00
David Markowitz
83707e2210 feat: add the lightning orb script* (#2011)
* feat: add the lightning orb script*

Doesn't do anything because MPC don't work...

* Update LightningOrbServer.cpp
2026-06-21 00:30:44 -05:00
David Markowitz
ccc029424c fix: fv mpc in the tree (#2010)
tested that it now spawns in the lower path point instead.

fixes #741
2026-06-20 18:53:49 -05:00
David Markowitz
ccad52a5ce feat: testmap improvements (#2009)
* feat: testmap improvements

fixes #1191

removed the force flag because it would only work to let you softlock your character.

tested that taking the lego club door now spawns you at the lego club/ns lego club doors always vs letting you spawn where ever.

tested that a testmap no longer spawns you where you last were in a zone and instead spawns you at the spawn point

inspect allows you to inspect zoneControl and localCharacter now
updated docs with the new info

* Update Entity.cpp
2026-06-20 17:10:13 -05:00
David Markowitz
135aec8112 feat: hatchlings (#2008)
tested that hatchlings now function
fixes #759

Update MovementAIComponent.cpp

Update MovementAIComponent.cpp

Update HatchlingPets.cpp
2026-06-20 17:10:00 -05:00
David Markowitz
a1891955e2 fix: leave team when fully logged out (#2007)
* feat: spawner weights

* remove ref

* default weights to 1

* fix: remove team member if they've logged out

tested that if i logout, after 20 seconds the team member is removed.
2026-06-20 02:50:49 -07:00
David Markowitz
7456d6b5c1 feat: spawner weights (#2006)
* feat: spawner weights

* remove ref

* default weights to 1
2026-06-19 19:08:15 -07:00
David Markowitz
308412f46e fix: enemies snapping to the incorrect position if they had a path and trying to use the path if they were aggro'd to an enemy (#2005)
* fix: enemies snapping to the incorrect position if they had a path

tested that ags enemies no longer snap backwards a large amount

* fix: move the home point so we can aggro correctly
2026-06-19 02:12:47 -07:00
David Markowitz
56504d9447 fix: add range checks to npc combat skill behavior (#2003)
* fix: add range checks to npc combat skill behavior

tested that all enemies now cast skills smartly based on range to targets, and do not cast skills if they are out of range.

fixes an issue where the spider queen could attack you outside the normal range

fixes an issue where entering happy flower caused you to need to restart the client

fixes #965

* feedback
2026-06-19 01:27:49 -05:00
David Markowitz
ce9d4e823c feat: enemies now use weights on their attacks (#2004)
* feat: enemies now use weights on their attacks

tested that 8 times out of 10, in close range, spiders did a web attack instead of a melee attack, vs the prior behavior of always following a pattern

fixes #2002

* feedback
2026-06-19 01:27:14 -05:00
David Markowitz
0f17e1de3b feat: enemy npc pathing (#2000)
* feat: enemy npc pathing

they live 🎉
tested that enemies path all around the world should they have a path configured.
tested that the admiral in gf (at the first camp) paths now.
fixes #1546

* feedback
2026-06-17 23:07:36 -07:00
David Markowitz
c898356eba fix: enemies not interrupting QB's when they do damage (#1998)
tested that stromlings in AG now correctly interrupt quickbuilds if the player takes damage
2026-06-16 09:49:56 -05:00
David Markowitz
79bb48d3bc feat: implement a bunch of basic scripts that don't really do anything (#1999)
* feat: implement a bunch of basic scripts that don't really do anything

None of these do anything noticeable or break anything

* fixes
2026-06-16 09:49:30 -05:00
David Markowitz
c0d055e66e fix dragon fire trails (#1997)
tested that the fire breath attack works now (hack fix)
2026-06-15 09:44:24 -07:00
David Markowitz
7937951f7f fix: mech build not showing up (#1996)
tested that the mech build, the fv rock build, and the frakjaw builds (to get to the instancer) all function as intented
2026-06-15 02:20:43 -05:00
David Markowitz
0101933f5c chore: cleanup pointer management for LDF data (#1995)
* change network settings from vector to LwoNameValue

* move settings on Entity to managed memory

* Migrate more members

* chore: remove pointer leakage from raw ldf pointers

* feedback

* fix ci
2026-06-14 20:54:52 -07:00
David Markowitz
90db1ac699 fix: incorrect network variable being set (#1994) 2026-06-13 00:21:53 -07:00
Daniel Seiler
9f8d300340 fix(docker): Unset MAXIMUM_OUTGOING_BANDWIDTH by default (#1548) 2026-06-11 18:38:38 -07:00
David Markowitz
1e9b18fa9d chore: cleanup usage of pointers in the activity component (#1989)
* chore: cleanup usage of pointers in the activity component

* feedback
2026-06-11 09:12:43 -05:00
David Markowitz
e5b8e5c6b7 fix: buggy hitboxes during ag foot race (#1993)
* physics fixes

* check ptr
2026-06-11 09:12:31 -05:00
David Markowitz
707880b5fc fix: fv pipe quick build not spawning as it should (#1991)
tested that the pipe now spawns a ROCK that you can build.  This ROCK you build spawns the PIPE now.
new bug: if you start building the ROCK and stop, the pipe will spawn instead of the previous rock.
2026-06-11 09:12:06 -05:00
David Markowitz
90607bdd5c fix: setting smashable ignoring lnv settings (#1992)
tested that the computer build on crux no longer stays around for +12 seconds
2026-06-11 09:11:52 -05:00
David Markowitz
bb8f569354 chore: Remove pointer usage in trading (#1988)
* chore: Remove pointer usage in trading

tested that I could still do a trade

* Update TradingManager.h

* remove returned object
2026-06-09 16:05:21 -05:00
David Markowitz
93076dc36d chore: simplify metrics (#1987)
* chore: simplify metrics

rename to hpp and remove unused includes

* feedback
2026-06-08 21:42:32 -07:00
David Markowitz
a307f0601a fix: bugs in private instances causing master crashes (#1986)
* fix: bugs in private instances causing master crashes

tested that creating a private instance and shutting down the server no longer crashes master

* Update InstanceManager.cpp
2026-06-08 23:22:04 -05:00
David Markowitz
045e097b13 chore: cleanup zoneIM (#1985) 2026-06-08 22:57:33 -05:00
Aaron Kimbrell
ca0da9d3bf feat: preconditions improvements (#1983)
* feat: implement missing precondition types (20, 21, 23) and pet checks

Add DoesNotHaveFlag (23), NotFreeTrial (20), and MissionActive (21) to
PreconditionType and implement their checks. Also implement PetDeployed
and IsPetTaming using PetComponent static helpers, matching client
behavior — both are simple boolean checks with no LOT comparison.
LegoClubMember is set to always pass as DLU has no membership concept.

* fix: update TODO comments for team check and racing licence preconditions

* type

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-08 22:45:24 -05:00
David Markowitz
1d2de705fb fix: old man npc mission (#1982)
* fix: old man npc mission

tested that the repeatable daily now has to actually be done and also can actually be done.

* Update OldManNPC.cpp
2026-06-08 20:44:29 -07:00
David Markowitz
a156a8fcba fix: security vulnerabilities (#1980)
* fix: security vulnerabilities

Tested that all functions related to the touched files work

will test sqlite on a CI build

* fix failing test

* ai feedback

* add buffer size checking

* use c_str

* dont log session key

* Try this for a mac definition

* be quiet apple
2026-06-07 20:59:11 -07:00
Aaron Kimbrell
f6c9a27a2b fix: update permissions for PR title check workflow (#1981) 2026-06-07 02:07:17 -07:00
Aaron Kimbrell
8e09ffd6e8 feat: enhance CI/CD workflows with Docker support and artifact management (#1977)
* feat: enhance CI/CD workflows with Docker support and artifact management

* chore: update GitHub Actions workflows with version pinning and permission adjustments

* feat: update CI workflows to support new OS versions and improve artifact handling

* chore: remove macOS-specific installation of libssl and XCode switching

* fix: update artifact zipping logic to target build directories in canary and release workflows

* chore: update actions/checkout to version 6.0.2 in CI workflow

* fix: update continue-on-error condition in CI workflow and add macOS RelWithDebInfo build preset

* fix: rename step to accurately reflect Docker image signing process

* don't ignore pdb's
2026-06-06 01:30:12 -05:00
David Markowitz
0c1808686c fix: tac arc absolute value bug (#1979)
Tested that tac arc correctly checks the full range
2026-06-06 01:17:00 -05:00
Johntor
4d6a624da2 docs: Refine command documentation (#1978)
* Refine command documentation

Updated command descriptions From dGame/dUtilities/SlashCommandHandler.cpp. Added aliases and improved formatting for better readability.

* fix: grammatical feedback

---------

Co-authored-by: David Markowitz <39972741+EmosewaMC@users.noreply.github.com>
2026-06-02 00:00:13 -07:00
David Markowitz
4ab09cf1aa Remove non-functioning saving of gm invis, re-add gm invis as a feature (#1976)
Does not save, only works for this world.  Fixed an issue where the incorrect comparison was used to make players invisible again (the same check that makes then INvisible needs to make them visible.)
2026-05-27 04:35:00 -05:00
David Markowitz
4ef9f43266 feat: make gm registration simpler and safer (#1932)
* gm registration re-work

* fix errors

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* remove duplicate message

* Remove duplicate function

* add null check

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-19 13:42:56 -05:00
David Markowitz
f3a5add038 fix(tac arc): incorrect check causing any enemy lower than you to not attack you (#1975)
tested that close range enemies above and below you now correctly attack you
2026-05-18 03:15:03 -07:00
David Markowitz
f5d33a773a fix: security fixes (#1974)
* fix: security fixes

dont print passwords for worlds
bound strings from clients
actually enable encryption between rakpeers
dont allow underflow when reading a string

Tested that packets are encrypted
tested that models can still be built
tested that combat still works

* add check

* use c++ nullptr instead of NULL

* initialize to 0

* globalize constant (should be in a namespace at least in the future)

* Update GameMessages.cpp

* check bounds
2026-05-17 14:21:22 -05:00
David Markowitz
67bbe4c1f0 fix(Missions): mission progression undefined behavior (#1973)
* fix: mission progression undefined behavior

defer the sub calls until after the loop has finished, that way no ub happens.  tested that mission progression all the way up until joining a faction still works and meta missions still function.

* default initialize

* Update MissionComponent.h
2026-05-17 14:21:08 -05:00
David Markowitz
482ff82656 fix: adding custom behaviors (#1969) 2026-04-14 01:04:26 -07:00
David Markowitz
8061f512aa feat: fix go outside and play mission (#1967)
* feat: fix go outside and play mission

* Update dWorldServer/WorldServer.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dGame/dComponents/MissionComponent.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* const

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-09 00:55:57 -05:00
David Markowitz
247576e101 fix: use copy ellision (#1963)
* use copy ellision

tested that the server still starts

* Update dDatabase/GameDatabase/MySQL/MySQLDatabase.h

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-31 15:35:28 -07:00
David Markowitz
8dfdca7fbd feat: add mission progression for behaviors (#1962)
* feat: add mission progression for behaviors

* Add const to ptr

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-31 13:02:23 -07:00
Aaron Kimbrell
8283d1fa95 fix: mariadb on newer gcc and newer cmake version (#1961)
* Fix newer gcc issues in mariadb

* fix error with newer cmake versions

* update mariadb to latest

* fix macos and docker

* fix: update Windows MSI package comments and align Connector/C++ version

* Update cmake/FindMariaDB.cmake

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: only pass CMAKE_POLICY_VERSION_MINIMUM to ExternalProject when CMake >= 4.0

Agent-Logs-Url: https://github.com/DarkflameUniverse/DarkflameServer/sessions/a247f729-a0b1-4fb6-825e-d23045b1ee55

Co-authored-by: aronwk-aaron <26027722+aronwk-aaron@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-03-29 13:59:09 -05:00
Aaron Kimbrell
f658da19a3 WIP 2026-03-26 09:56:29 -05:00
Aaron Kimbrell
8372202d8f WIP working state 2026-02-26 09:56:11 -06:00
David Markowitz
434c9b6315 fix: imaginite on racing minigames and add null checks (#1958) 2026-02-23 01:16:36 -08:00
David Markowitz
3c64b26c39 fix: macos ci (#1955) 2026-02-11 19:49:51 -08:00
David Markowitz
347b1d17d4 fix: not checking pending names on rename (#1954) 2026-02-11 19:49:39 -08:00
Aaron Kimbrell
f1847d1f20 WIP: basic server, no features 2026-01-25 22:33:51 -06:00
David Markowitz
c723ce2588 fix: donations requiring new high score vs adding to previous one (#1951)
tested that jawbox works as intended now for donation counting on the leaderboards
2026-01-13 22:48:29 -08:00
Terrev
66b7d3606e fix: flower activity (#1950) 2025-12-26 13:58:10 -08:00
David Markowitz
40fef36530 fix: coins dropping on killer (#1948)
tested that when a player dies the coins spawn on their body instead
2025-12-13 22:00:58 -08:00
David Markowitz
bf020baa17 fix: temp fixes for ghosting so I can continue being on break (#1947)
* fix: temp fixes for ghosting so I can continue being on break

disables the ghost feature for now so i can continue my break

* Update GhostComponent.cpp
2025-12-08 20:39:33 -08:00
David Markowitz
a713216540 fix: saving gm invis for non gms (#1940) 2025-11-18 22:04:07 -06:00
David Markowitz
ea86a708e4 fix: uninitialized memory (#1937) 2025-11-18 19:06:03 -08:00
David Markowitz
ca7424cbeb fix: chest loot not working (#1933)
* fix bons and dragon loot

* fix chest server loot
2025-11-16 16:17:49 -06:00
David Markowitz
991e55f305 feat: dont drop loot for dead players if configured in the zone activity settings (#1935)
* feat: dont drop loot for dead players if configured in the zone activity settings

* fix errors

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dGame/dComponents/ActivityComponent.h

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update dGame/dUtilities/Loot.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-16 16:17:26 -06:00
David Markowitz
5410acffaa fix: chat server crash (#1931)
* fix: chat server crash

* Update dChatServer/ChatServer.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-16 13:48:57 -08:00
David Markowitz
86f8601bbd feat: various debug command improvements (#1934)
* feat: various debug command improvements

* add missing utility function

* Update dGame/dUtilities/SlashCommands/DEVGMCommands.cpp

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-16 13:48:16 -08:00
David Markowitz
4658318a3a fix: deactivate bubble buff from server too (#1936) 2025-11-16 13:46:59 -08:00
Aaron Kimbrell
11d44ffb98 feat: proper gminvs with ghosting (#1920)
* feat: proper gminvis ghosting

* address feedback

---------

Co-authored-by: David Markowitz <39972741+EmosewaMC@users.noreply.github.com>
2025-11-15 16:43:33 -08:00
David Markowitz
2fb16420f3 fix: ape anchor not respawning (#1927)
* fix: ape anchor not respawning

* add return

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 13:30:02 -08:00
David Markowitz
96089a8d9a fix: fb race activityid (#1929) 2025-11-15 13:29:11 -08:00
David Markowitz
eac50acfcc fix: correct mission tracking (#1930)
checked that live captures did not track achievements in this count
2025-11-15 13:29:03 -08:00
David Markowitz
ca60787055 fix: ffa -> shared loot for activities (#1925) 2025-10-26 01:01:21 -07:00
David Markowitz
396dcb0465 feat: add logger feature to log on function entry and exit (#1924)
* feat: add logger feature to log on function entry and exit

* i didnt save the file
2025-10-25 14:53:49 -05:00
David Markowitz
6e545eb1b9 Update Loot.cpp (#1923) 2025-10-24 21:53:00 -07:00
David Markowitz
46aac016fd fix: unintended stopping (#1922) 2025-10-23 23:41:16 -05:00
David Markowitz
83823fa64f fix: resurrect not available for non-gms (#1919) 2025-10-20 23:05:22 -07:00
David Markowitz
0dd504c803 feat: behavior states (#1918) 2025-10-20 01:16:36 -05:00
David Markowitz
a70c365c23 feat banana (#1917) 2025-10-19 14:00:14 -05:00
David Markowitz
281d9762ef fix: tac arc sorting and target acquisition (#1916) 2025-10-19 07:23:54 -05:00
David Markowitz
002aa896d8 feat: debug information (#1915) 2025-10-19 07:22:45 -05:00
David Markowitz
f3a5f60d81 feat: more destroyable debug info (#1912)
* feat: more destroyable info

* Change type and remove duplicate value
2025-10-16 14:15:02 -05:00
David Markowitz
4c9c773ec5 fix: powerup drops and hardcore loot drops (#1914)
tested the following are now functional
ag buff station
tiki torch
ve rocket part boxes
ns statue
property behavior
extra items from full inventory
hardcore drops (items and coins)
2025-10-16 14:13:38 -05:00
David Markowitz
ec6253c80c fix: coin dupe on same team (#1911)
* feat: Loot rework

* Allow dupe powerup pickups

* change default team loot to shared

* fix: coin dupe on team
2025-10-15 22:36:45 -05:00
Aaron Kimbrell
c2dba31f70 fix: bbb splitting dupe issue (#1908)
* fix bbb group splitting issues

* address feedback
2025-10-15 16:45:09 -07:00
David Markowitz
74630b56c8 feat: Loot rework (#1909)
* feat: Loot rework

* Allow dupe powerup pickups

* change default team loot to shared
2025-10-15 00:53:39 -05:00
David Markowitz
fd6029ae10 feat: read from server macros folder as well (#1906) 2025-10-11 15:33:38 -07:00
David Markowitz
ff645a6662 feat: model debug (#1907) 2025-10-11 15:33:28 -07:00
David Markowitz
e051229fb6 feat: InventoryComponent debug info (#1902) 2025-10-11 00:58:52 -05:00
Aaron Kimbrell
ce28834dce feat: lxfml splitting for bbb (#1877)
* LXFML SPLITTING
Included test file

* move base to global namespace

* wip need to test

* update last fixes

* update world sending bbb to be more efficient

* Address feedback form Emo in doscord

* Make LXFML class for robust and add more tests to edge cases and malformed data

* get rid of the string copy and make the deep clone have a recursive limit

* cleanup tests

* fix test file locations

* fix file path

* KISS

* add cmakelists

* fix typos

* NL @ EOF

* tabs and split out to func

* naming standard
2025-10-10 23:07:16 -05:00
David Markowitz
cbdd5d9bc6 fix: dying while dead (#1905) 2025-10-10 01:15:21 -05:00
David Markowitz
62ac65c520 feat: Mission Component debug (#1901)
* feat: Mission Component debug

* Add player argument to inspect command

* Add completion details

* Remove unlocalized server string

done on client instead
2025-10-05 22:13:27 -05:00
HailStorm32
5d5bce53d0 feat: Add configurable restrictions for muted accounts (#1887)
* Add configurable restrictions for muted accounts

* switched to and updated GetRandomElement

* Update config option check

* implement cached config values for mute settings and update handlers

* Address review

* Update dGame/dComponents/PetComponent.cpp

Co-authored-by: David Markowitz <39972741+EmosewaMC@users.noreply.github.com>

* Update dGame/dComponents/PetComponent.cpp

Co-authored-by: David Markowitz <39972741+EmosewaMC@users.noreply.github.com>

* reduce if argument chain

---------

Co-authored-by: David Markowitz <39972741+EmosewaMC@users.noreply.github.com>
2025-10-05 22:09:43 -05:00
David Markowitz
5791c55a9e fix: the exploding script is the most amazing piece of code i have ev… (#1900)
* fix: the exploding script is the most amazing piece of code i have ever had the pleasure of working with and has been amazing to work on and translate from lua

hahahahahahahahahahwwwwwwwwwwwwwwww草

* Enhance hit detection with proximity object checks

Refactor hit handling to include proximity checks for destroyable entities.
2025-10-05 00:19:46 -07:00
David Markowitz
17d0c45382 fix: why oh why is the aggro radius apart of the enemy (#1899) 2025-10-04 20:45:42 -07:00
David Markowitz
7dbbef81ac fix: regenerated proxy items dont need new ids and fix equip item ids (#1897)
* fix: changed item ids not reflected in equipped items

* dont do it for proxy items
2025-10-04 18:42:34 -07:00
David Markowitz
06958cb9cd feat: hardcore limit % coins dropped on death (#1898)
* feat: hardcore limit % coins dropped on death

Update EntityManager.cpp

* fix log msg
2025-10-04 17:25:23 -07:00
David Markowitz
69b1a694a6 fix: ignore foreign key checks more (#1895)
fixes an issue if you delete users in an earlier build of dlu.
2025-10-04 13:57:16 -05:00
David Markowitz
b2609ff6cb fix: live accurate player flag missions and flag debugging (#1894)
* feat: Add component ID to root component object

* fix: live accurate player flag missions and flag debugging

Tested that the client reflects the correct server progression after a test map and manually setting a flag off.
tested that session flags correctly pick up on progression updates

* banana
2025-10-04 01:07:52 -05:00
David Markowitz
e8c0b3e6da feat: Add component ID to root component object (#1893) 2025-10-03 20:57:42 -05:00
David Markowitz
25418fd8b2 fix: exploding asset bugs (#1890) 2025-10-01 20:48:08 -05:00
David Markowitz
502c965d97 feat: script debug info (#1891) 2025-10-01 14:21:25 -05:00
David Markowitz
205c190c61 fix: proxy items not equipping on login (#1892)
tested that items now equip on login
2025-10-01 07:55:51 -05:00
David Markowitz
670cb124c0 Initialize m_ActivityInfo with default constructor (#1889) 2025-09-30 23:33:04 -05:00
David Markowitz
76c2f380bf feat: re-write persistent object ID tracker (#1888)
* feat: re-write persistent object ID tracker

Features:
- Remove random objectIDs entirely
- Replace random objectIDs with persistentIDs
- Remove the need to contact the MASTER server for a persistent ID
- Add persistent ID logic to WorldServers that use transactions to guarantee unique IDs no matter when they are generated
- Default character xml version to be the most recent one

Fixes:
- Return optional from GetModel (and check for nullopt where it may exist)
- Regenerate inventory item ids on first login to be unique item IDs (fixes all those random IDs

Pet IDs and subkeys are left alone and are assumed to be reserved (checks are there to prevent this)
There is also duplicate check logic in place for properties and UGC/Models

* Update comment and log

* fix: sqlite transaction bug

* fix colliding temp item ids

temp items should not be saved. would cause issues between worlds as experienced before this commit
2025-09-29 08:54:37 -05:00
David Markowitz
b5a3cc9187 fix: Extend saved ugc id to 64 bits (#1885)
Tetsed that ugc models are saved and loaded with the correct bits
2025-09-24 06:01:46 -05:00
David Markowitz
74e1d36bb1 feat: Hardcore mode settings (#1884) 2025-09-23 07:02:29 -05:00
David Markowitz
64faac714c feat: Remove PERSISTENT ObjectID bit because it's not an ObjectID bit (#1881)
* feat: Remove PERSISTENT ObjectID bit because it's not an ObjectID bit

TODO: Need to add character save migration for the pet subkey in the inventory
Tested that the migrations work on mysql and sqlite and that properties have all their contents as before.
Need to test pets still

* fix: ugc, pet ids. remove persistent bit
2025-09-22 23:41:38 -05:00
David Markowitz
4a5dd68e87 fix: hardcore mode fixes (#1882)
fixes hardcore modes uscore drops
adds config option for excluded item drops
2025-09-21 18:36:32 -07:00
David Markowitz
4a577f233d fix: hardcore mode fixes (#1883)
fixes hardcore modes uscore drops
adds config option for excluded item drops

f

feat: Add logging for config options on load and reload

feat: Add logging for config options on load and reload
2025-09-21 20:12:50 -05:00
David Markowitz
bb05b3ac0d feat: Add logging for testing Johnny missions (#1880) 2025-09-20 17:22:16 -07:00
David Markowitz
06022e4b19 fix: use after free in TCPInterface (#1879)
lol
2025-09-19 01:12:34 -05:00
David Markowitz
6389876c6e feat: convert character ids to 64 bits (#1878)
* feat: convert character ids to 64 bits

remove all usages of the PERSISTENT bit with regards to storing of playerIDs on the server.  the bit does not exist and was a phantom in the first place.
Tested that a full playthrough of ag, ns and gf was still doable.  slash commands work, ugc works, friends works, ignore list works, properties work and have names, teaming works.
migrating an old mysql database works . need to test an old sqlite database

* fix sqlite migration

* remove nd specific column migration
2025-09-19 01:12:23 -05:00
David Markowitz
68f2e2dee2 Fix FetchContent_Declare speed (#1875) 2025-09-12 03:32:15 -05:00
507 changed files with 19847 additions and 4464 deletions

29
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,29 @@
# GitHub Copilot Instructions
* c++20 standard, please use the latest features except NO modules.
* use `.contains` for searching in associative containers
* use const as much as possible. If it can be const, it should be made const
* DO NOT USE const_cast EVER.
* use `cstdint` bitwidth types ALWAYS for integral types.
* NEVER use std::wstring. If wide strings are necessary, use std::u16string with conversion utilties in GeneralUtils.h.
* Functions are ALWAYS PascalCase.
* local variables are camelCase
* NEVER use snake case
* indentation is TABS, not SPACES.
* TABS are 4 spaces by default
* Use trailing braces ALWAYS
* global variables are prefixed with `g_`
* if global variables or functions are needed, they should be located in an anonymous namespace
* Use `GeneralUtils::TryParse` for ANY parsing of strings to integrals.
* Use brace initialization when possible.
* ALWAYS default initialize variables.
* Pointers should be avoided unless necessary. Use references when the pointer has been checked and should not be null
* headers should be as compact as possible. Do NOT include extra data that isnt needed.
* Remember to include logs (LOG macro uses printf style logging) while putting verbose logs under LOG_DEBUG.
* NEVER USE `RakNet::BitStream::ReadBit`
* NEVER assume pointers are good, always check if they are null. Once a pointer is checked and is known to be non-null, further accesses no longer need checking
* Be wary of TOCTOU. Prevent all possible issues relating to TOCTOU.
* new memory allocations should never be used unless absolutely necessary.
* new for reconstruction of objects is allowed
* Prefer following the format of the file over correct formatting. Consistency over correctness.
* When using auto, ALWAYS put a * for pointers.

View File

@@ -1,14 +1,14 @@
name: CI
name: Docker
on:
push:
branches:
- "main"
- main
tags:
- "v*.*.*"
pull_request:
branches:
- "main"
- main
env:
REGISTRY: ghcr.io
@@ -20,15 +20,21 @@ jobs:
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to the Container registry
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -36,21 +42,32 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=pr
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}
type=raw,value=canary,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
id: push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Sign Docker image
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

View File

@@ -3,6 +3,8 @@ name: CI
on:
push:
branches: [ main ]
tags:
- "v*.*.*"
pull_request:
branches: [ main ]
@@ -10,38 +12,51 @@ jobs:
build-and-test:
name: Build & Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
continue-on-error: true
continue-on-error: ${{ github.event_name == 'pull_request' }}
strategy:
matrix:
os: [ windows-2022, ubuntu-22.04, macos-13 ]
include:
- os: windows-2025
artifact: windows
debug_preset: windows-msvc-relwithdebinfo
- os: ubuntu-24.04
artifact: linux
debug_preset: linux-gnu-relwithdebinfo
- os: macos-15-intel
artifact: macos
debug_preset: macos-relwithdebinfo
steps:
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: true
- name: Add msbuild to PATH (Windows only)
if: ${{ matrix.os == 'windows-2022' }}
uses: microsoft/setup-msbuild@767f00a3f09872d96a0cb9fcd5e6a4ff33311330
if: ${{ matrix.os == 'windows-2025' }}
uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3
with:
vs-version: '[17,18)'
vs-version: '[18,19)'
msbuild-architecture: x64
- name: Install libssl and switch to XCode 15.2 (Mac Only)
if: ${{ matrix.os == 'macos-13' }}
run: |
brew install openssl@3
sudo xcode-select -s /Applications/Xcode_15.2.app/Contents/Developer
- name: Get CMake 3.x
uses: lukka/get-cmake@28983e0d3955dba2bb0a6810caae0c6cf268ec0c
- name: Get CMake
uses: lukka/get-cmake@591817e96fcad43505fb4eae36172462abb3a42e # v4.3.3
with:
cmakeVersion: "~3.25.0" # <--= optional, use most recent 3.25.x version
cmakeVersion: "latest"
- name: cmake
uses: lukka/run-cmake@67c73a83a46f86c4e0b96b741ac37ff495478c38
uses: lukka/run-cmake@5d55ea7949e25f69f0ecb516d8d572297e03a956 # v10.9
with:
workflowPreset: "ci-${{matrix.os}}"
workflowPreset: "${{ matrix.debug_preset }}"
- name: Extract Linux debug symbols
if: matrix.os == 'ubuntu-24.04'
run: |
find build -type f -name '*Server' | while read bin; do
objcopy --only-keep-debug "$bin" "${bin}.debug"
objcopy --strip-debug --add-gnu-debuglink="${bin}.debug" "$bin"
done
- name: artifacts
uses: actions/upload-artifact@6027e3dd177782cd8ab9af838c04fd81a07f1d47
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: build-${{matrix.os}}
name: build-${{matrix.artifact}}
path: |
build/*/*Server*
build/*/*.ini
@@ -52,5 +67,30 @@ jobs:
build/*/navmeshes/
build/*/migrations/
build/*/*.dcf
!build/*/*.pdb
!build/*/d*/
!build/*/*.dSYM/
!build/**/*.debug
- name: debug symbols (Windows)
if: matrix.os == 'windows-2025'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: debug-${{matrix.artifact}}
path: |
build/*/*.pdb
build/*/d*/
retention-days: 30
- name: debug symbols (Linux)
if: matrix.os == 'ubuntu-24.04'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: debug-${{matrix.artifact}}
path: build/**/*.debug
retention-days: 30
- name: debug symbols (macOS)
if: matrix.os == 'macos-15-intel'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: debug-${{matrix.artifact}}
path: build/**/*.dSYM/
retention-days: 30

93
.github/workflows/canary.yml vendored Normal file
View File

@@ -0,0 +1,93 @@
name: Canary
on:
workflow_run:
workflows: ["CI"]
branches: [main]
types: [completed]
permissions:
contents: write
actions: read
jobs:
canary-release:
name: Publish Canary Release
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
- name: Get last release tag
id: last_tag
run: |
tag=$(git describe --tags --abbrev=0 --match "v*.*.*" 2>/dev/null || echo "none")
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Generate changelog since last release tag
uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4.8.0
id: cliff
with:
config: cliff.toml
args: --unreleased --strip header
env:
OUTPUT: CHANGES.md
GITHUB_REPO: ${{ github.repository }}
- name: Prepend header to changelog
run: |
last="${{ steps.last_tag.outputs.tag }}"
sha="${{ github.event.workflow_run.head_sha }}"
short="${sha:0:7}"
if [ "$last" != "none" ]; then
header="Changes since **$last** ([full diff](https://github.com/${{ github.repository }}/compare/${last}...${sha}))\n\n"
else
header="Changes up to \`${short}\`\n\n"
fi
printf "%b" "$header" | cat - CHANGES.md > CHANGES.tmp && mv CHANGES.tmp CHANGES.md
- name: Download artifacts from CI run
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
with:
run_id: ${{ github.event.workflow_run.id }}
path: artifacts/
- name: Package artifacts
run: |
declare -A platform_map=(
["build-windows"]="darkflame-universe-windows"
["build-linux"]="darkflame-universe-linux"
["build-macos"]="darkflame-universe-macos"
)
cd artifacts
for dir in build-*/; do
name="${dir%/}"
out="${platform_map[$name]:-$name}"
zip -r "../${out}.zip" "$dir"
done
cd ..
ls -lh *.zip
- name: Delete existing canary release
run: gh release delete canary --yes --cleanup-tag 2>/dev/null || true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create canary pre-release
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
with:
tag: canary
name: "Canary ${{ steps.last_tag.outputs.tag }}+${{ github.event.workflow_run.head_sha }}"
bodyFile: CHANGES.md
artifacts: "*.zip"
artifactContentType: application/zip
token: ${{ secrets.GITHUB_TOKEN }}
prerelease: true
draft: false
allowUpdates: true
removeArtifacts: true
commit: ${{ github.event.workflow_run.head_sha }}

38
.github/workflows/pr-title-check.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: PR Title Check
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: write
statuses: write
jobs:
check-title:
name: Conventional Commit Title
runs-on: ubuntu-latest
steps:
- name: Check PR title follows Conventional Commits
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
perf
refactor
docs
chore
test
ci
revert
requireScope: false
subjectPattern: ^.+$
subjectPatternError: |
The PR title "{title}" must have a description after the type/scope prefix.
Example: "feat: add new login flow" or "fix(auth): handle null token"
wip: true
validateSingleCommit: false

70
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
name: Release
on:
workflow_run:
workflows: ["CI"]
types: [completed]
permissions:
contents: write
actions: read
jobs:
release:
name: Create Release
# Only run when CI completed successfully on a tag push (not a PR branch named like a version)
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
startsWith(github.event.workflow_run.head_branch, 'v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
- name: Generate changelog
uses: orhun/git-cliff-action@f50e11560dce63f7c33227798f90b924471a88b5 # v4.8.0
id: cliff
with:
config: cliff.toml
args: --latest --strip header
env:
OUTPUT: CHANGES.md
GITHUB_REPO: ${{ github.repository }}
- name: Download artifacts from CI run
uses: dawidd6/action-download-artifact@b6e2e70617bc3265edd6dab6c906732b2f1ae151 # v21
with:
run_id: ${{ github.event.workflow_run.id }}
path: artifacts/
- name: Package artifacts
run: |
declare -A platform_map=(
["build-windows"]="darkflame-universe-windows"
["build-linux"]="darkflame-universe-linux"
["build-macos"]="darkflame-universe-macos"
)
cd artifacts
for dir in build-*/; do
name="${dir%/}"
out="${platform_map[$name]:-$name}"
zip -r "../${out}.zip" "$dir"
done
cd ..
ls -lh *.zip
- name: Create GitHub Release
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
with:
tag: ${{ github.event.workflow_run.head_branch }}
name: ${{ github.event.workflow_run.head_branch }}
bodyFile: CHANGES.md
artifacts: "*.zip"
artifactContentType: application/zip
token: ${{ secrets.GITHUB_TOKEN }}
prerelease: false
draft: false

2
.gitignore vendored
View File

@@ -126,3 +126,5 @@ docker-compose.override.yml
# CMake scripts
!cmake/*
!cmake/toolchains/*
.mcp.json
.claude/

View File

@@ -15,6 +15,7 @@ set(CMAKE_C_STANDARD 99)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # Export the compile commands for debugging
set(CMAKE_POLICY_DEFAULT_CMP0063 NEW) # Set CMAKE visibility policy to NEW on project and subprojects
set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) # Set C and C++ symbol visibility to hide inlined functions
@@ -67,7 +68,11 @@ set(RECASTNAVIGATION_EXAMPLES OFF CACHE BOOL "" FORCE)
# Disabled no-register
# Disabled unknown pragmas because Linux doesn't understand Windows pragmas.
if(UNIX)
add_link_options("-Wl,-rpath,$ORIGIN/")
if(APPLE)
add_link_options("-Wl,-rpath,@loader_path/")
else()
add_link_options("-Wl,-rpath,$ORIGIN/")
endif()
add_compile_options("-fPIC")
add_compile_definitions(_GLIBCXX_USE_CXX11_ABI=0 _GLIBCXX_USE_CXX17_ABI=0)
@@ -110,6 +115,8 @@ set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR})
find_package(MariaDB)
find_package(OpenSSL REQUIRED)
# Create a /resServer directory
make_directory(${CMAKE_BINARY_DIR}/resServer)
@@ -126,7 +133,7 @@ endif()
message(STATUS "Variable: DLU_CONFIG_DIR = ${DLU_CONFIG_DIR}")
# Copy resource files on first build
set(RESOURCE_FILES "sharedconfig.ini" "authconfig.ini" "chatconfig.ini" "worldconfig.ini" "masterconfig.ini" "blocklist.dcf")
set(RESOURCE_FILES "sharedconfig.ini" "authconfig.ini" "chatconfig.ini" "worldconfig.ini" "masterconfig.ini" "dashboardconfig.ini" "blocklist.dcf")
message(STATUS "Checking resource file integrity")
include(Utils)
@@ -322,6 +329,7 @@ endif()
add_subdirectory(dWorldServer)
add_subdirectory(dAuthServer)
add_subdirectory(dChatServer)
add_subdirectory(dDashboardServer)
add_subdirectory(dMasterServer) # Add MasterServer last so it can rely on the other binaries
target_precompile_headers(

View File

@@ -49,7 +49,7 @@
"inherits": "default",
"displayName": "[Multi] Windows (MSVC)",
"description": "Set architecture to 64-bit (b/c RakNet)",
"generator": "Visual Studio 17 2022",
"generator": "Visual Studio 18 2026",
"binaryDir": "${sourceDir}/build/msvc",
"architecture": {
"value": "x64"
@@ -162,6 +162,13 @@
"rhs": "Darwin"
},
"binaryDir": "${sourceDir}/build/macos"
},
{
"name": "macos-relwithdebinfo",
"inherits": ["macos", "relwithdebinfo-config"],
"displayName": "[RelWithDebInfo] MacOS",
"description": "Create a RelWithDebInfo build for MacOS",
"binaryDir": "${sourceDir}/build/macos-relwithdebinfo"
}
],
"buildPresets": [
@@ -255,7 +262,7 @@
{
"name": "macos-relwithdebinfo",
"inherits": "default",
"configurePreset": "macos",
"configurePreset": "macos-relwithdebinfo",
"displayName": "[RelWithDebInfo] MacOS",
"description": "This preset is used to build in release mode with debug info on MacOS",
"configuration": "RelWithDebInfo"
@@ -374,7 +381,7 @@
{
"name": "macos-relwithdebinfo",
"inherits": "default",
"configurePreset": "macos",
"configurePreset": "macos-relwithdebinfo",
"displayName": "[RelWithDebInfo] MacOS",
"description": "Runs all tests on a MacOS configuration",
"configuration": "RelWithDebInfo"
@@ -603,7 +610,7 @@
"steps": [
{
"type": "configure",
"name": "macos"
"name": "macos-relwithdebinfo"
},
{
"type": "build",
@@ -616,7 +623,7 @@
]
},
{
"name": "ci-macos-13",
"name": "ci-macos-15-intel",
"displayName": "[Release] MacOS",
"description": "CI workflow preset for MacOS",
"steps": [

View File

@@ -16,7 +16,9 @@ RUN --mount=type=cache,target=/app/build,id=build-cache \
cd /app/build && \
cmake .. && \
make -j$(nproc --ignore 1) && \
cp -r /app/build/* /tmp/persisted-build/
cp -r /app/build/* /tmp/persisted-build/ && \
mkdir -p /tmp/persisted-build/mariadbcpp && \
cp /app/build/thirdparty/mariadb-connector-cpp/src/mariadb_connector_cpp-build/libmariadbcpp.so /tmp/persisted-build/mariadbcpp/
FROM debian:12 as runtime

35
cliff.toml Normal file
View File

@@ -0,0 +1,35 @@
[changelog]
header = ""
body = """
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/{{ remote.github.owner }}/{{ remote.github.repo }}/commit/{{ commit.id }}))\
{% if commit.github.username %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){% endif %}
{% endfor %}
{% endfor %}
"""
footer = ""
trim = true
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactoring" },
{ message = "^docs", group = "Documentation" },
{ message = "^chore", group = "Chores" },
{ message = "^test", group = "Testing" },
{ message = "^ci", group = "CI/CD" },
{ message = "^revert", group = "Reverts" },
]
filter_commits = false
tag_pattern = "v[0-9].*"
skip_tags = "canary"
ignore_tags = ""
topo_order = false
sort_commits = "oldest"

View File

@@ -7,6 +7,7 @@ FetchContent_Declare(
GIT_REPOSITORY https://github.com/google/googletest.git
GIT_TAG release-1.12.1
GIT_PROGRESS TRUE
GIT_SHALLOW 1
)
# For Windows: Prevent overriding the parent project's compiler/linker settings

View File

@@ -10,14 +10,15 @@ if(WIN32 AND NOT MARIADB_BUILD_SOURCE)
file(MAKE_DIRECTORY "${MARIADB_MSI_DIR}")
file(MAKE_DIRECTORY "${MARIADB_CONNECTOR_DIR}")
# These values need to be updated whenever a new minor release replaces an old one
# Go to https://mariadb.com/downloads/connectors/ to find the up-to-date URL parts
set(MARIADB_CONNECTOR_C_VERSION "3.2.7")
set(MARIADB_CONNECTOR_C_BUCKET "2319651")
set(MARIADB_CONNECTOR_C_MD5 "f8636d733f1d093af9d4f22f3239f885")
set(MARIADB_CONNECTOR_CPP_VERSION "1.0.2")
set(MARIADB_CONNECTOR_CPP_BUCKET "2531525")
set(MARIADB_CONNECTOR_CPP_MD5 "3034bbd6ca00a0125345f9fd1a178401")
# These values track the published Windows MSI packages used by the prebuilt path.
# Keep the Connector/C++ package version aligned with the checked out submodule tag when possible.
# Go to https://mariadb.com/downloads/connectors/ to find the up-to-date URL parts.
set(MARIADB_CONNECTOR_C_VERSION "3.4.8")
set(MARIADB_CONNECTOR_C_BUCKET "4516894")
set(MARIADB_CONNECTOR_C_MD5 "50f6fc0c77b8d3bacbeac0126e179861")
set(MARIADB_CONNECTOR_CPP_VERSION "1.1.7")
set(MARIADB_CONNECTOR_CPP_BUCKET "4464908")
set(MARIADB_CONNECTOR_CPP_MD5 "08644a7ff084b5933325cadb904796e5")
set(MARIADB_CONNECTOR_C_MSI "mariadb-connector-c-${MARIADB_CONNECTOR_C_VERSION}-win64.msi")
set(MARIADB_CONNECTOR_CPP_MSI "mariadb-connector-cpp-${MARIADB_CONNECTOR_CPP_VERSION}-win64.msi")
@@ -79,23 +80,39 @@ else() # Build from source
-DWITH_EXTERNAL_ZLIB=ON
-DOPENSSL_ROOT_DIR=${OPENSSL_ROOT_DIR}
-DCMAKE_C_FLAGS=-w # disable zlib warnings
-DCMAKE_CXX_FLAGS=-D_GLIBCXX_USE_CXX11_ABI=0)
-DCMAKE_CXX_FLAGS=-D_GLIBCXX_USE_CXX11_ABI=0\ -Wno-inconsistent-missing-override\ -include\ cstdint)
else()
set(MARIADB_EXTRA_CMAKE_ARGS
-DCMAKE_C_FLAGS=-w # disable zlib warnings
-DCMAKE_CXX_FLAGS=-D_GLIBCXX_USE_CXX11_ABI=0)
-DCMAKE_CXX_FLAGS=-D_GLIBCXX_USE_CXX11_ABI=0\ -include\ cstdint)
endif()
set(MARIADBCPP_BUILD_DIR "${PROJECT_BINARY_DIR}/thirdparty/mariadb-connector-cpp/src/mariadb_connector_cpp-build")
set(MARIADBCPP_INSTALL_DIR ${PROJECT_BINARY_DIR}/prefix)
set(MARIADBCPP_LIBRARY_DIR ${PROJECT_BINARY_DIR}/mariadbcpp)
set(MARIADBCPP_PLUGIN_DIR ${MARIADBCPP_LIBRARY_DIR}/plugin)
set(MARIADBCPP_SOURCE_DIR ${PROJECT_SOURCE_DIR}/thirdparty/mariadb-connector-cpp)
set(MARIADB_INCLUDE_DIR "${MARIADBCPP_SOURCE_DIR}/include")
if(WIN32)
set(MARIADBCPP_LIBRARY_DIR ${PROJECT_BINARY_DIR}/mariadbcpp)
set(MARIADBCPP_PLUGIN_DIR ${MARIADBCPP_LIBRARY_DIR}/plugin)
set(MARIADB_INSTALL_COMMAND)
else()
set(MARIADBCPP_LIBRARY_DIR ${MARIADBCPP_BUILD_DIR})
set(MARIADBCPP_PLUGIN_DIR ${MARIADBCPP_BUILD_DIR}/libmariadb)
set(MARIADB_INSTALL_COMMAND INSTALL_COMMAND ${CMAKE_COMMAND} -E true)
endif()
if(CMAKE_VERSION VERSION_GREATER_EQUAL "4.0")
set(MARIADB_POLICY_VERSION_ARG -DCMAKE_POLICY_VERSION_MINIMUM=3.5)
endif()
ExternalProject_Add(mariadb_connector_cpp
PREFIX "${PROJECT_BINARY_DIR}/thirdparty/mariadb-connector-cpp"
SOURCE_DIR ${MARIADBCPP_SOURCE_DIR}
BINARY_DIR ${MARIADBCPP_BUILD_DIR}
INSTALL_DIR ${MARIADBCPP_INSTALL_DIR}
CMAKE_ARGS -Wno-dev
${MARIADB_POLICY_VERSION_ARG}
-DWITH_UNIT_TESTS=OFF
-DMARIADB_LINK_DYNAMIC=OFF
-DCMAKE_BUILD_RPATH_USE_ORIGIN=${CMAKE_BUILD_RPATH_USE_ORIGIN}
@@ -103,6 +120,7 @@ else() # Build from source
-DINSTALL_LIBDIR=${MARIADBCPP_LIBRARY_DIR}
-DINSTALL_PLUGINDIR=${MARIADBCPP_PLUGIN_DIR}
${MARIADB_EXTRA_CMAKE_ARGS}
${MARIADB_INSTALL_COMMAND}
BUILD_ALWAYS true
)

View File

@@ -52,6 +52,7 @@ int main(int argc, char** argv) {
//Create all the objects we need to run our service:
Server::SetupLogger("AuthServer");
if (!Game::logger) return EXIT_FAILURE;
Game::config->LogSettings();
LOG("Starting Auth server...");
LOG("Version: %s", PROJECT_VERSION);

View File

@@ -3,6 +3,7 @@
#include "MessageType/Chat.h"
#include "BitStreamUtils.h"
#include "Game.h"
#include "dConfig.h"
#include "Logger.h"
#include "eObjectBits.h"
@@ -34,7 +35,7 @@ void ChatIgnoreList::GetIgnoreList(Packet* packet) {
if (!receiver.ignoredPlayers.empty()) {
LOG_DEBUG("Player %llu already has an ignore list, but is requesting it again.", playerId);
} else {
auto ignoreList = Database::Get()->GetIgnoreList(static_cast<uint32_t>(playerId));
auto ignoreList = Database::Get()->GetIgnoreList(playerId);
if (ignoreList.empty()) {
LOG_DEBUG("Player %llu has no ignores", playerId);
return;
@@ -43,7 +44,6 @@ void ChatIgnoreList::GetIgnoreList(Packet* packet) {
for (auto& ignoredPlayer : ignoreList) {
receiver.ignoredPlayers.emplace_back(ignoredPlayer.name, ignoredPlayer.id);
GeneralUtils::SetBit(receiver.ignoredPlayers.back().playerId, eObjectBits::CHARACTER);
GeneralUtils::SetBit(receiver.ignoredPlayers.back().playerId, eObjectBits::PERSISTENT);
}
}
@@ -73,8 +73,8 @@ void ChatIgnoreList::AddIgnore(Packet* packet) {
return;
}
constexpr int32_t MAX_IGNORES = 32;
if (receiver.ignoredPlayers.size() > MAX_IGNORES) {
const int32_t MAX_IGNORES = Game::config->GetValue("max_ignores", 32);
if (receiver.ignoredPlayers.size() >= MAX_IGNORES) {
LOG_DEBUG("Player %llu has too many ignores", playerId);
return;
}
@@ -114,9 +114,8 @@ void ChatIgnoreList::AddIgnore(Packet* packet) {
}
if (ignoredPlayerId != LWOOBJID_EMPTY) {
Database::Get()->AddIgnore(static_cast<uint32_t>(playerId), static_cast<uint32_t>(ignoredPlayerId));
Database::Get()->AddIgnore(playerId, ignoredPlayerId);
GeneralUtils::SetBit(ignoredPlayerId, eObjectBits::CHARACTER);
GeneralUtils::SetBit(ignoredPlayerId, eObjectBits::PERSISTENT);
receiver.ignoredPlayers.emplace_back(toIgnoreStr, ignoredPlayerId);
LOG_DEBUG("Player %llu is ignoring %s", playerId, toIgnoreStr.c_str());
@@ -157,7 +156,7 @@ void ChatIgnoreList::RemoveIgnore(Packet* packet) {
return;
}
Database::Get()->RemoveIgnore(static_cast<uint32_t>(playerId), static_cast<uint32_t>(toRemove->playerId));
Database::Get()->RemoveIgnore(playerId, toRemove->playerId);
receiver.ignoredPlayers.erase(toRemove, receiver.ignoredPlayers.end());
CBITSTREAM;

View File

@@ -35,7 +35,6 @@ void ChatPacketHandler::HandleFriendlistRequest(Packet* packet) {
FriendData fd;
fd.isFTP = false; // not a thing in DLU
fd.friendID = friendData.friendID;
GeneralUtils::SetBit(fd.friendID, eObjectBits::PERSISTENT);
GeneralUtils::SetBit(fd.friendID, eObjectBits::CHARACTER);
fd.isBestFriend = friendData.isBestFriend; //0 = friends, 1 = left_requested, 2 = right_requested, 3 = both_accepted - are now bffs
@@ -161,9 +160,7 @@ void ChatPacketHandler::HandleFriendRequest(Packet* packet) {
// Set the bits
GeneralUtils::SetBit(queryPlayerID, eObjectBits::CHARACTER);
GeneralUtils::SetBit(queryPlayerID, eObjectBits::PERSISTENT);
GeneralUtils::SetBit(queryFriendID, eObjectBits::CHARACTER);
GeneralUtils::SetBit(queryFriendID, eObjectBits::PERSISTENT);
// Since this player can either be the friend of someone else or be friends with someone else
// their column in the database determines what bit gets set. When the value hits 3, they
@@ -318,7 +315,6 @@ void ChatPacketHandler::HandleRemoveFriend(Packet* packet) {
}
// Convert friendID to LWOOBJID
GeneralUtils::SetBit(friendID, eObjectBits::PERSISTENT);
GeneralUtils::SetBit(friendID, eObjectBits::CHARACTER);
Database::Get()->RemoveFriend(playerID, friendID);
@@ -439,6 +435,11 @@ void ChatPacketHandler::HandleChatMessage(Packet* packet) {
inStream.IgnoreBytes(4);
inStream.Read(channel);
inStream.Read(size);
if (size > MAX_MESSAGE_LENGTH) {
LOG("Received a probably spoofed chat message, ignoring msg");
return;
}
inStream.IgnoreBytes(77);
LUWString message(size);
@@ -483,6 +484,11 @@ void ChatPacketHandler::HandlePrivateChatMessage(Packet* packet) {
if (channel != eChatChannel::PRIVATE_CHAT) LOG("WARNING: Received Private chat with the wrong channel!");
inStream.Read(size);
if (size > MAX_MESSAGE_LENGTH) {
LOG("Received a probably spoofed chat message, ignoring msg");
return;
}
inStream.IgnoreBytes(77);
inStream.Read(LUReceiverName);

View File

@@ -59,6 +59,7 @@ int main(int argc, char** argv) {
//Create all the objects we need to run our service:
Server::SetupLogger("ChatServer");
if (!Game::logger) return EXIT_FAILURE;
Game::config->LogSettings();
//Read our config:
@@ -201,8 +202,11 @@ int main(int argc, char** argv) {
//Delete our objects here:
Database::Destroy("ChatServer");
delete Game::server;
Game::server = nullptr;
delete Game::logger;
Game::logger = nullptr;
delete Game::config;
Game::config = nullptr;
return EXIT_SUCCESS;
}

View File

@@ -19,23 +19,24 @@
#include "eGameMasterLevel.h"
#include "dChatFilter.h"
#include "TeamContainer.h"
#include "HTTPContext.h"
using json = nlohmann::json;
void HandleHTTPPlayersRequest(HTTPReply& reply, std::string body) {
void HandleHTTPPlayersRequest(HTTPReply& reply, const HTTPContext& context) {
const json data = Game::playerContainer;
reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK;
reply.message = data.empty() ? "{\"error\":\"No Players Online\"}" : data.dump();
}
void HandleHTTPTeamsRequest(HTTPReply& reply, std::string body) {
void HandleHTTPTeamsRequest(HTTPReply& reply, const HTTPContext& context) {
const json data = TeamContainer::GetTeamContainer();
reply.status = data.empty() ? eHTTPStatusCode::NO_CONTENT : eHTTPStatusCode::OK;
reply.message = data.empty() ? "{\"error\":\"No Teams Online\"}" : data.dump();
}
void HandleHTTPAnnounceRequest(HTTPReply& reply, std::string body) {
auto data = GeneralUtils::TryParse<json>(body);
void HandleHTTPAnnounceRequest(HTTPReply& reply, const HTTPContext& context) {
auto data = GeneralUtils::TryParse<json>(context.body);
if (!data) {
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
@@ -96,18 +97,21 @@ namespace ChatWeb {
Game::web.RegisterHTTPRoute({
.path = v1_route + "players",
.method = eHTTPMethod::GET,
.middleware = {},
.handle = HandleHTTPPlayersRequest
});
Game::web.RegisterHTTPRoute({
.path = v1_route + "teams",
.method = eHTTPMethod::GET,
.middleware = {},
.handle = HandleHTTPTeamsRequest
});
Game::web.RegisterHTTPRoute({
.path = v1_route + "announce",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = HandleHTTPAnnounceRequest
});

View File

@@ -105,15 +105,7 @@ void PlayerContainer::RemovePlayer(const LWOOBJID playerID) {
auto* team = TeamContainer::GetTeam(playerID);
if (team != nullptr) {
const auto memberName = GeneralUtils::UTF8ToUTF16(player.playerName);
for (const auto memberId : team->memberIDs) {
const auto& otherMember = GetPlayerData(memberId);
if (!otherMember) continue;
TeamContainer::SendTeamSetOffWorldFlag(otherMember, playerID, { 0, 0, 0 });
}
TeamContainer::RemoveMember(team, playerID, false, false, true);
}
ChatWeb::SendWSPlayerUpdate(player, eActivityType::PlayerLoggedOut);
@@ -176,6 +168,7 @@ LWOOBJID PlayerContainer::GetId(const std::u16string& playerName) {
return toReturn;
}
// TODO Make this a pointer again or do something to make it so you cant edit the LWOOBJID_EMPTY entry? it should be ignored in all cases anyways though...
PlayerData& PlayerContainer::GetPlayerDataMutable(const LWOOBJID& playerID) {
return m_Players.contains(playerID) ? m_Players[playerID] : m_Players[LWOOBJID_EMPTY];
}

View File

@@ -477,7 +477,7 @@ TeamData* TeamContainer::CreateLocalTeam(std::vector<LWOOBJID> members) {
}
}
newTeam->lootFlag = 1;
newTeam->lootFlag = 0;
TeamStatusUpdate(newTeam);

View File

@@ -3,6 +3,7 @@
#include <stdexcept>
#include "Amf3.h"
#include "StringifiedEnum.h"
/**
* AMF3 Reference document https://rtmp.veriskope.com/pdf/amf3-file-format-spec.pdf
@@ -53,7 +54,7 @@ std::unique_ptr<AMFBaseValue> AMFDeserialize::Read(RakNet::BitStream& inStream)
case eAmf::VectorObject:
[[fallthrough]];
case eAmf::Dictionary:
throw marker;
throw std::invalid_argument(StringifiedEnum::ToString(marker).data());
default:
throw std::invalid_argument("Invalid AMF3 marker" + std::to_string(static_cast<int32_t>(marker)));
}
@@ -88,6 +89,11 @@ const std::string AMFDeserialize::ReadString(RakNet::BitStream& inStream) {
// Right shift by 1 bit to get index if reference or size of next string if value
length = length >> 1;
if (isReference) {
constexpr int32_t maxStringSize = 1024 * 1024;
if (length > maxStringSize) {
LOG("1MB string attempted to be allocated in AMF deserialize, possible spoof, aborting deserialize.");
throw std::invalid_argument("1MB string attempted to be allocated in AMF deserialize, possible spoof, aborting deserialize.");
}
std::string value(length, 0);
inStream.Read(&value[0], length);
// Empty strings are never sent by reference
@@ -117,6 +123,12 @@ std::unique_ptr<AMFArrayValue> AMFDeserialize::ReadAmfArray(RakNet::BitStream& i
if (key.size() == 0) break;
arrayValue->Insert(key, Read(inStream));
}
constexpr int32_t maxArraySize = 10'000;
if (sizeOfDenseArray > maxArraySize) {
LOG("Someone sent 10,000 dense array entries, probably a bad packet.");
throw std::invalid_argument("Someone sent 10,000 dense array entries, probably a bad packet.");
}
// Finally read dense portion
for (uint32_t i = 0; i < sizeOfDenseArray; i++) {
arrayValue->Insert(i, Read(inStream));

View File

@@ -4,7 +4,6 @@
#include "dCommonVars.h"
#include "Logger.h"
#include "Game.h"
#include <type_traits>
#include <unordered_map>
#include <vector>
@@ -368,12 +367,55 @@ public:
}
template<typename AmfType = AMFArrayValue>
AmfType& PushDebug(const std::string_view name) {
AmfType& PushDebug(const std::string_view name, const std::string& objectType = "", const uint32_t warningLevel = 0) {
size_t i = 0;
for (; i < m_Dense.size(); i++) {
const auto& cast = dynamic_cast<AMFArrayValue*>(m_Dense[i].get());
if (!cast) continue;
const auto& nameValue = cast->Get<std::string>("name");
if (!nameValue || nameValue->GetValue() != name) continue;
if (!objectType.empty()) {
cast->Insert<std::string>("type", objectType);
}
if (warningLevel != 0) {
cast->Insert<double>("warningLevel", warningLevel);
}
// found a duplicate, return this instead
auto valueCast = dynamic_cast<AmfType*>(cast->Get("value"));
if (valueCast) return *valueCast;
}
auto* value = PushArray();
value->Insert("name", name.data());
value->Insert<std::string>("name", name.data());
if (!objectType.empty()) {
value->Insert<std::string>("type", objectType);
}
if (warningLevel != 0) {
value->Insert<double>("warningLevel", warningLevel);
}
return value->Insert<AmfType>("value", std::make_unique<AmfType>());
}
AMFArrayValue& PushDebug(const NiPoint3& point) {
PushDebug<AMFDoubleValue>("X") = point.x;
PushDebug<AMFDoubleValue>("Y") = point.y;
PushDebug<AMFDoubleValue>("Z") = point.z;
return *this;
}
AMFArrayValue& PushDebug(const NiQuaternion& rot) {
PushDebug<AMFDoubleValue>("W") = rot.w;
PushDebug<AMFDoubleValue>("X") = rot.x;
PushDebug<AMFDoubleValue>("Y") = rot.y;
PushDebug<AMFDoubleValue>("Z") = rot.z;
return *this;
}
private:
/**
* The associative portion. These values are key'd with strings to an AMFValue.

View File

@@ -52,8 +52,7 @@ uint32_t BrickByBrickFix::TruncateBrokenBrickByBrickXml() {
if (actualUncompressedSize != -1) {
uint32_t previousSize = completeUncompressedModel.size();
completeUncompressedModel.append(reinterpret_cast<char*>(uncompressedChunk.get()));
completeUncompressedModel.resize(previousSize + actualUncompressedSize);
completeUncompressedModel.append(reinterpret_cast<char*>(uncompressedChunk.get()), actualUncompressedSize);
} else {
LOG("Failed to inflate chunk %i for model %llu. Error: %i", chunkCount, model.id, err);
break;

View File

@@ -49,24 +49,24 @@ if (UNIX)
elseif (WIN32)
include(FetchContent)
# TODO Keep an eye on the zlib repository for an update to disable testing. Don't forget to update CMakePresets
FetchContent_Declare(
zlib
URL https://github.com/madler/zlib/archive/refs/tags/v1.2.11.zip
URL_HASH MD5=9d6a627693163bbbf3f26403a3a0b0b1
URL https://github.com/madler/zlib/archive/refs/tags/v1.3.2.zip
URL_HASH MD5=adbba6eef8960c3412818b2e241f46dc
GIT_PROGRESS TRUE
GIT_SHALLOW 1
)
# Disable warning about no project version.
set(CMAKE_POLICY_DEFAULT_CMP0048 NEW)
# Disable warning about the minimum version of cmake used for bcrypt being deprecated in the future
set(CMAKE_WARN_DEPRECATED OFF CACHE BOOL "" FORCE)
# Disable zlib tests
set(ZLIB_BUILD_TESTING OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(zlib)
set(ZLIB_INCLUDE_DIRS ${zlib_SOURCE_DIR} ${zlib_BINARY_DIR})
set_target_properties(zlib PROPERTIES INTERFACE_INCLUDE_DIRECTORIES "${ZLIB_INCLUDE_DIRS}")
add_library(ZLIB::ZLIB ALIAS zlib)
else ()
message(
FATAL_ERROR

View File

@@ -308,8 +308,9 @@ std::vector<std::string> GeneralUtils::GetSqlFileNamesFromFolder(const std::stri
for (const auto& t : std::filesystem::directory_iterator(folder)) {
if (t.is_directory() || t.is_symlink()) continue;
auto filename = t.path().filename().string();
const auto index = std::stoi(GeneralUtils::SplitString(filename, '_').at(0));
filenames.emplace(index, std::move(filename));
// Ensure the file has a name in the format of xxxxxxxx_anything_goes_here.sql
const auto migrationNumber = TryParse<uint32_t>(GeneralUtils::SplitString(filename, '_').at(0));
if (migrationNumber.has_value()) filenames.emplace(migrationNumber.value(), std::move(filename));
}
// Now sort the map by the oldest migration.

View File

@@ -3,7 +3,7 @@
// C++
#include <charconv>
#include <cstdint>
#include <cmath>
#include <cmath>
#include <ctime>
#include <functional>
#include <optional>
@@ -19,6 +19,7 @@
#include "dPlatforms.h"
#include "Game.h"
#include "Logger.h"
#include "DluAssert.h"
#include <glm/ext/vector_float3.hpp>
@@ -204,6 +205,12 @@ namespace GeneralUtils {
return isParsed ? static_cast<T>(result) : std::optional<T>{};
}
// A version of TryParse that will return `errorVal` if `str` failed to parse.
template <Numeric T>
[[nodiscard]] T TryParse(std::string_view str, const T errorVal) {
return TryParse<T>(str).value_or(errorVal);
}
template<typename T>
requires(!Numeric<T>)
[[nodiscard]] std::optional<T> TryParse(std::string_view str);
@@ -236,6 +243,12 @@ namespace GeneralUtils {
return std::nullopt;
}
// A version of TryParse that will return `errorVal` if `str` failed to parse.
template <std::floating_point T>
[[nodiscard]] T TryParse(std::string_view str, const T errorVal) {
return TryParse<T>(str).value_or(errorVal);
}
#endif
/**
@@ -257,6 +270,11 @@ namespace GeneralUtils {
return z ? std::make_optional<T>(x.value(), y.value(), z.value()) : std::nullopt;
}
// Alternative overload of TryParse with a default value
[[nodiscard]] inline NiPoint3 TryParse(const std::string_view strX, const std::string_view strY, const std::string_view strZ, const NiPoint3 errorVal) {
return TryParse<NiPoint3>(strX, strY, strZ).value_or(errorVal);
}
/**
* The TryParse overload for handling NiPoint3 by passing a span of three strings
* @param str The string vector representing the X, Y, and Z coordinates
@@ -267,6 +285,11 @@ namespace GeneralUtils {
return (str.size() == 3) ? TryParse<T>(str[0], str[1], str[2]) : std::nullopt;
}
// Alternative overload of TryParse with a default value
[[nodiscard]] inline NiPoint3 TryParse(const std::span<const std::string> str, const NiPoint3 errorVal) {
return TryParse<NiPoint3>(str).value_or(errorVal);
}
template <typename T>
std::u16string to_u16string(const T value) {
return GeneralUtils::ASCIIToUTF16(std::to_string(value));
@@ -305,7 +328,7 @@ namespace GeneralUtils {
template<typename Container>
inline Container::value_type GetRandomElement(const Container& container) {
DluAssert(!container.empty());
return container[GenerateRandomNumber<typename Container::value_type>(0, container.size() - 1)];
return container[GenerateRandomNumber<typename Container::size_type>(0, container.size() - 1)];
}
/**

View File

@@ -10,163 +10,151 @@
#include <string_view>
#include <vector>
using LDFKey = std::string_view;
using LDFTypeAndValue = std::string_view;
using LDFType = std::string_view;
using LDFValue = std::string_view;
//! Returns a pointer to a LDFData value based on string format
LDFBaseData* LDFBaseData::DataFromString(const std::string_view& format) {
std::unique_ptr<LDFBaseData> LDFBaseData::DataFromString(const std::string_view& format) {
std::unique_ptr<LDFBaseData> toReturn;
// A valid LDF must be at least 3 characters long (=0:) is the shortest valid LDF (empty UTF-16 key with no initial value)
if (format.empty() || format.length() <= 2) return nullptr;
auto equalsPosition = format.find('=');
// You can have an empty key, just make sure the type and value might exist
if (equalsPosition == std::string::npos || equalsPosition == (format.size() - 1)) return nullptr;
if (!format.empty() && format.length() > 2) {
auto equalsPosition = format.find('=');
// You can have an empty key, just make sure the type and value might exist
if (equalsPosition != std::string::npos && equalsPosition != (format.size() - 1)) {
std::pair<LDFKey, LDFTypeAndValue> keyValue;
keyValue.first = format.substr(0, equalsPosition);
keyValue.second = format.substr(equalsPosition + 1, format.size());
const std::string_view keyValue = format.substr(0, equalsPosition);
const std::string_view typeAndValue = format.substr(equalsPosition + 1, format.size());
std::u16string key = GeneralUtils::ASCIIToUTF16(keyValue.first);
const auto key = GeneralUtils::ASCIIToUTF16(keyValue);
auto colonPosition = keyValue.second.find(':');
const auto colonPosition = typeAndValue.find(':');
// If : is the first thing after an =, then this is an invalid LDF since
// we dont have a type to use.
if (colonPosition == std::string::npos || colonPosition == 0) return nullptr;
// If : is the first thing after an =, then this is an invalid LDF since
// we dont have a type to use.
if (colonPosition != std::string::npos && colonPosition != 0) {
const std::string_view ldfType = typeAndValue.substr(0, colonPosition);
const std::string_view ldfValue = typeAndValue.substr(colonPosition + 1, typeAndValue.size());
std::pair<LDFType, LDFValue> ldfTypeAndValue;
ldfTypeAndValue.first = keyValue.second.substr(0, colonPosition);
ldfTypeAndValue.second = keyValue.second.substr(colonPosition + 1, keyValue.second.size());
// Only allow empty values for string values.
if (!ldfValue.empty() || (ldfType == "0" /* UTF-16 */ || ldfType == "13" /* UTF-8 */)) {
const eLDFType type = GeneralUtils::TryParse<eLDFType>(ldfType, LDF_TYPE_UNKNOWN);
switch (type) {
case LDF_TYPE_UTF_16: {
std::u16string data = GeneralUtils::UTF8ToUTF16(ldfValue);
toReturn.reset(new LDFData<std::u16string>(key, data));
break;
}
// Only allow empty values for string values.
if (ldfTypeAndValue.second.size() == 0 && !(ldfTypeAndValue.first == "0" || ldfTypeAndValue.first == "13")) return nullptr;
case LDF_TYPE_S32: {
const auto data = GeneralUtils::TryParse<int32_t>(ldfValue);
if (data) {
toReturn.reset(new LDFData<int32_t>(key, data.value()));
} else {
LOG("Warning: Attempted to process invalid int32 value (%s) from string (%s)", ldfValue.data(), format.data());
}
eLDFType type;
char* storage;
try {
type = static_cast<eLDFType>(strtol(ldfTypeAndValue.first.data(), &storage, 10));
} catch (std::exception) {
LOG("Attempted to process invalid ldf type (%s) from string (%s)", ldfTypeAndValue.first.data(), format.data());
return nullptr;
}
break;
}
LDFBaseData* returnValue = nullptr;
switch (type) {
case LDF_TYPE_UTF_16: {
std::u16string data = GeneralUtils::UTF8ToUTF16(ldfTypeAndValue.second);
returnValue = new LDFData<std::u16string>(key, data);
break;
}
case LDF_TYPE_FLOAT: {
const auto data = GeneralUtils::TryParse<float>(ldfValue);
if (data) {
toReturn.reset(new LDFData<float>(key, data.value()));
} else {
LOG("Warning: Attempted to process invalid float value (%s) from string (%s)", ldfValue.data(), format.data());
}
break;
}
case LDF_TYPE_S32: {
const auto data = GeneralUtils::TryParse<int32_t>(ldfTypeAndValue.second);
if (!data) {
LOG("Warning: Attempted to process invalid int32 value (%s) from string (%s)", ldfTypeAndValue.second.data(), format.data());
return nullptr;
}
returnValue = new LDFData<int32_t>(key, data.value());
case LDF_TYPE_DOUBLE: {
const auto data = GeneralUtils::TryParse<double>(ldfValue);
if (data) {
toReturn.reset(new LDFData<double>(key, data.value()));
} else {
LOG("Warning: Attempted to process invalid double value (%s) from string (%s)", ldfValue.data(), format.data());
}
break;
}
break;
}
case LDF_TYPE_U32:
{
uint32_t data;
bool parsed = true;
// Have to do this really weird parsing to allow for copy ellision
if (ldfValue == "true") {
data = 1;
} else if (ldfValue == "false") {
data = 0;
} else {
const auto dataOptional = GeneralUtils::TryParse<uint32_t>(ldfValue);
if (!dataOptional) {
LOG("Warning: Attempted to process invalid uint32 value (%s) from string (%s)", ldfValue.data(), format.data());
parsed = false;
} else {
data = dataOptional.value();
}
}
case LDF_TYPE_FLOAT: {
const auto data = GeneralUtils::TryParse<float>(ldfTypeAndValue.second);
if (!data) {
LOG("Warning: Attempted to process invalid float value (%s) from string (%s)", ldfTypeAndValue.second.data(), format.data());
return nullptr;
}
returnValue = new LDFData<float>(key, data.value());
break;
}
if (parsed) toReturn.reset(new LDFData<uint32_t>(key, data));
break;
}
case LDF_TYPE_DOUBLE: {
const auto data = GeneralUtils::TryParse<double>(ldfTypeAndValue.second);
if (!data) {
LOG("Warning: Attempted to process invalid double value (%s) from string (%s)", ldfTypeAndValue.second.data(), format.data());
return nullptr;
}
returnValue = new LDFData<double>(key, data.value());
break;
}
case LDF_TYPE_BOOLEAN: {
bool data;
bool parsed = true;
// Have to do this really weird parsing to allow for copy ellision
if (ldfValue == "true") {
data = true;
} else if (ldfValue == "false") {
data = false;
} else {
const auto dataOptional = GeneralUtils::TryParse<bool>(ldfValue);
if (!dataOptional) {
LOG("Warning: Attempted to process invalid bool value (%s) from string (%s)", ldfValue.data(), format.data());
parsed = false;
} else {
data = dataOptional.value();
}
}
case LDF_TYPE_U32:
{
uint32_t data;
if (parsed) toReturn.reset(new LDFData<bool>(key, data));
break;
}
if (ldfTypeAndValue.second == "true") {
data = 1;
} else if (ldfTypeAndValue.second == "false") {
data = 0;
} else {
const auto dataOptional = GeneralUtils::TryParse<uint32_t>(ldfTypeAndValue.second);
if (!dataOptional) {
LOG("Warning: Attempted to process invalid uint32 value (%s) from string (%s)", ldfTypeAndValue.second.data(), format.data());
return nullptr;
case LDF_TYPE_U64: {
const auto data = GeneralUtils::TryParse<uint64_t>(ldfValue);
if (data) {
toReturn.reset(new LDFData<uint64_t>(key, data.value()));
} else {
LOG("Warning: Attempted to process invalid uint64 value (%s) from string (%s)", ldfValue.data(), format.data());
}
break;
}
case LDF_TYPE_OBJID: {
const auto data = GeneralUtils::TryParse<LWOOBJID>(ldfValue);
if (data) {
toReturn.reset(new LDFData<LWOOBJID>(key, data.value()));
} else {
LOG("Warning: Attempted to process invalid LWOOBJID value (%s) from string (%s)", ldfValue.data(), format.data());
}
break;
}
case LDF_TYPE_UTF_8: {
toReturn.reset(new LDFData<std::string>(key, ldfValue.data()));
break;
}
case LDF_TYPE_UNKNOWN:
[[fallthrough]];
default: {
LOG("Warning: Attempted to process invalid unknown value (%s) from string (%s)", ldfValue.data(), format.data());
break;
}
}
}
}
data = dataOptional.value();
}
returnValue = new LDFData<uint32_t>(key, data);
break;
}
case LDF_TYPE_BOOLEAN: {
bool data;
if (ldfTypeAndValue.second == "true") {
data = true;
} else if (ldfTypeAndValue.second == "false") {
data = false;
} else {
const auto dataOptional = GeneralUtils::TryParse<bool>(ldfTypeAndValue.second);
if (!dataOptional) {
LOG("Warning: Attempted to process invalid bool value (%s) from string (%s)", ldfTypeAndValue.second.data(), format.data());
return nullptr;
}
data = dataOptional.value();
}
returnValue = new LDFData<bool>(key, data);
break;
}
case LDF_TYPE_U64: {
const auto data = GeneralUtils::TryParse<uint64_t>(ldfTypeAndValue.second);
if (!data) {
LOG("Warning: Attempted to process invalid uint64 value (%s) from string (%s)", ldfTypeAndValue.second.data(), format.data());
return nullptr;
}
returnValue = new LDFData<uint64_t>(key, data.value());
break;
}
case LDF_TYPE_OBJID: {
const auto data = GeneralUtils::TryParse<LWOOBJID>(ldfTypeAndValue.second);
if (!data) {
LOG("Warning: Attempted to process invalid LWOOBJID value (%s) from string (%s)", ldfTypeAndValue.second.data(), format.data());
return nullptr;
}
returnValue = new LDFData<LWOOBJID>(key, data.value());
break;
}
case LDF_TYPE_UTF_8: {
std::string data = ldfTypeAndValue.second.data();
returnValue = new LDFData<std::string>(key, data);
break;
}
case LDF_TYPE_UNKNOWN: {
LOG("Warning: Attempted to process invalid unknown value (%s) from string (%s)", ldfTypeAndValue.second.data(), format.data());
break;
}
default: {
LOG("Warning: Attempted to process invalid LDF type (%d) from string (%s)", type, format.data());
break;
}
}
return returnValue;
return toReturn;
}

View File

@@ -1,11 +1,12 @@
#ifndef __LDFFORMAT__H__
#define __LDFFORMAT__H__
#ifndef LDFFORMAT_H
#define LDFFORMAT_H
// Custom Classes
#include "dCommonVars.h"
#include "GeneralUtils.h"
// C++
#include <map>
#include <string>
#include <string_view>
#include <sstream>
@@ -46,17 +47,17 @@ public:
virtual std::string GetValueAsString() const = 0;
virtual LDFBaseData* Copy() const = 0;
virtual std::unique_ptr<LDFBaseData> Copy() const = 0;
/**
* Given an input string, return the data as a LDF key.
*/
static LDFBaseData* DataFromString(const std::string_view& format);
static std::unique_ptr<LDFBaseData> DataFromString(const std::string_view& format);
};
template<typename T>
class LDFData: public LDFBaseData {
class LDFData : public LDFBaseData {
private:
std::u16string key;
T value;
@@ -164,8 +165,8 @@ public:
return this->GetValueString();
}
LDFBaseData* Copy() const override {
return new LDFData<T>(key, value);
std::unique_ptr<LDFBaseData> Copy() const override {
return std::make_unique<LDFData<T>>(key, value);
}
inline static const T Default = {};
@@ -226,4 +227,89 @@ template<> inline std::string LDFData<LWOOBJID>::GetValueString() const { return
template<> inline std::string LDFData<std::string>::GetValueString() const { return this->value; }
#endif //!__LDFFORMAT__H__
struct LwoNameValue {
using LDFPtr = std::unique_ptr<LDFBaseData>;
using ValueType = std::map<std::u16string, LDFPtr>;
LwoNameValue& operator=(const LwoNameValue& other) {
this->values = other.Copy();
return *this;
}
template<typename T>
void Insert(const std::u16string& key, const T& value) {
this->values.insert_or_assign(key, std::unique_ptr(std::make_unique<LDFData<T>>(key, value)));
}
void Insert(const std::u16string& key, const char* value) {
this->Insert<std::string>(key, value);
}
void Insert(const std::u16string& key, const char16_t* value) {
this->Insert<std::u16string>(key, value);
}
template<typename T>
void Insert(const std::string& key, const T& value) {
this->Insert<T>(GeneralUtils::UTF8ToUTF16(key), value);
}
void Insert(const std::string& key, const char* value) {
this->Insert<std::string>(GeneralUtils::UTF8ToUTF16(key), value);
}
void Insert(const std::string& key, const char16_t* value) {
this->Insert<std::u16string>(GeneralUtils::UTF8ToUTF16(key), value);
}
const LDFPtr& ParseInsert(const std::string& data) {
LDFPtr toInsert(LDFBaseData::DataFromString(data));
return toInsert ?
this->values.insert_or_assign(toInsert->GetKey(), std::move(toInsert)).first->second :
this->values.insert_or_assign(u"FAILED_TO_PARSE_" + GeneralUtils::UTF8ToUTF16(data), std::make_unique<LDFData<std::string>>("", "")).first->second;
}
const LDFPtr& ParseInsert(const std::u16string& data) {
return this->ParseInsert(GeneralUtils::UTF16ToWTF8(data));
}
ValueType::const_iterator begin() const {
return this->values.cbegin();
}
ValueType::const_iterator end() const {
return this->values.cend();
}
void Erase(const std::u16string& key) {
this->values.erase(key);
}
void Erase(const std::string& key) {
this->Erase(GeneralUtils::ASCIIToUTF16(key));
}
ValueType::iterator find(const ValueType::key_type& key) {
return this->values.find(key);
}
ValueType::const_iterator find(const ValueType::key_type& key) const {
return this->values.find(key);
}
LwoNameValue() = default;
LwoNameValue(const LwoNameValue& other) {
this->values = other.Copy();
}
ValueType values;
private:
ValueType Copy() const {
ValueType copy;
for (const auto& [key, value] : this->values) copy.insert_or_assign(key, value->Copy());
return copy;
}
};
#endif //!LDFFORMAT_H

View File

@@ -96,3 +96,17 @@ bool Logger::GetLogToConsole() const {
}
return toReturn;
}
FuncEntry::FuncEntry(const char* funcName, const char* fileName, const uint32_t line) {
m_FuncName = funcName;
if (!m_FuncName) m_FuncName = "Unknown";
m_Line = line;
m_FileName = fileName;
LOG("--> %s::%s:%i", m_FileName, m_FuncName, m_Line);
}
FuncEntry::~FuncEntry() {
if (!m_FuncName || !m_FileName) return;
LOG("<-- %s::%s:%i", m_FileName, m_FuncName, m_Line);
}

View File

@@ -32,6 +32,19 @@ constexpr const char* GetFileNameFromAbsolutePath(const char* path) {
#define LOG(message, ...) do { auto str_ = FILENAME_AND_LINE; Game::logger->Log(str_, message, ##__VA_ARGS__); } while(0)
#define LOG_DEBUG(message, ...) do { auto str_ = FILENAME_AND_LINE; Game::logger->LogDebug(str_, message, ##__VA_ARGS__); } while(0)
// Place this right at the start of a function. Will log a message when called and then once you leave the function.
#define LOG_ENTRY auto str_ = GetFileNameFromAbsolutePath(__FILE__); FuncEntry funcEntry_(__FUNCTION__, str_, __LINE__)
class FuncEntry {
public:
FuncEntry(const char* funcName, const char* fileName, const uint32_t line);
~FuncEntry();
private:
const char* m_FuncName = nullptr;
const char* m_FileName = nullptr;
uint32_t m_Line = 0;
};
// Writer class for writing data to files.
class Writer {
public:

View File

@@ -5,13 +5,43 @@
#include "TinyXmlUtils.h"
#include <ranges>
#include <unordered_map>
#include <unordered_set>
#include <functional>
#include <sstream>
namespace {
// The base LXFML xml file to use when creating new models.
std::string g_base = R"(<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<LXFML versionMajor="5" versionMinor="0">
<Meta>
<Application name="LEGO Universe" versionMajor="0" versionMinor="0"/>
<Brand name="LEGOUniverse"/>
<BrickSet version="457"/>
</Meta>
<Bricks>
</Bricks>
<RigidSystems>
</RigidSystems>
<GroupSystems>
<GroupSystem>
</GroupSystem>
</GroupSystems>
</LXFML>)";
}
Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoint3& curPosition) {
Result toReturn;
// Handle empty or invalid input
if (data.empty()) {
return toReturn;
}
tinyxml2::XMLDocument doc;
const auto err = doc.Parse(data.data());
// Use length-based parsing to avoid expensive string copy
const auto err = doc.Parse(data.data(), data.size());
if (err != tinyxml2::XML_SUCCESS) {
LOG("Failed to parse xml %s.", StringifiedEnum::ToString(err).data());
return toReturn;
}
@@ -20,7 +50,6 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin
auto lxfml = reader["LXFML"];
if (!lxfml) {
LOG("Failed to find LXFML element.");
return toReturn;
}
@@ -49,16 +78,19 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin
// Calculate the lowest and highest points on the entire model
for (const auto& transformation : transformations | std::views::values) {
auto split = GeneralUtils::SplitString(transformation, ',');
if (split.size() < 12) {
LOG("Not enough in the split?");
continue;
}
auto x = GeneralUtils::TryParse<float>(split[9]).value();
auto y = GeneralUtils::TryParse<float>(split[10]).value();
auto z = GeneralUtils::TryParse<float>(split[11]).value();
if (x < lowest.x) lowest.x = x;
if (y < lowest.y) lowest.y = y;
if (split.size() < 12) continue;
auto xOpt = GeneralUtils::TryParse<float>(split[9]);
auto yOpt = GeneralUtils::TryParse<float>(split[10]);
auto zOpt = GeneralUtils::TryParse<float>(split[11]);
if (!xOpt.has_value() || !yOpt.has_value() || !zOpt.has_value()) continue;
auto x = xOpt.value();
auto y = yOpt.value();
auto z = zOpt.value();
if (x < lowest.x) lowest.x = x;
if (y < lowest.y) lowest.y = y;
if (z < lowest.z) lowest.z = z;
if (highest.x < x) highest.x = x;
@@ -87,13 +119,19 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin
for (auto& transformation : transformations | std::views::values) {
auto split = GeneralUtils::SplitString(transformation, ',');
if (split.size() < 12) {
LOG("Not enough in the split?");
continue;
}
auto x = GeneralUtils::TryParse<float>(split[9]).value() - newRootPos.x + curPosition.x;
auto y = GeneralUtils::TryParse<float>(split[10]).value() - newRootPos.y + curPosition.y;
auto z = GeneralUtils::TryParse<float>(split[11]).value() - newRootPos.z + curPosition.z;
auto xOpt = GeneralUtils::TryParse<float>(split[9]);
auto yOpt = GeneralUtils::TryParse<float>(split[10]);
auto zOpt = GeneralUtils::TryParse<float>(split[11]);
if (!xOpt.has_value() || !yOpt.has_value() || !zOpt.has_value()) {
continue;
}
auto x = xOpt.value() - newRootPos.x + curPosition.x;
auto y = yOpt.value() - newRootPos.y + curPosition.y;
auto z = zOpt.value() - newRootPos.z + curPosition.z;
std::stringstream stream;
for (int i = 0; i < 9; i++) {
stream << split[i];
@@ -128,3 +166,345 @@ Lxfml::Result Lxfml::NormalizePosition(const std::string_view data, const NiPoin
toReturn.center = newRootPos;
return toReturn;
}
// Deep-clone an XMLElement (attributes, text, and child elements) into a target document
// with maximum depth protection to prevent infinite loops
static tinyxml2::XMLElement* CloneElementDeep(const tinyxml2::XMLElement* src, tinyxml2::XMLDocument& dstDoc, int maxDepth = 100) {
if (!src || maxDepth <= 0) return nullptr;
auto* dst = dstDoc.NewElement(src->Name());
// copy attributes
for (const tinyxml2::XMLAttribute* attr = src->FirstAttribute(); attr; attr = attr->Next()) {
dst->SetAttribute(attr->Name(), attr->Value());
}
// copy children (elements and text)
for (const tinyxml2::XMLNode* child = src->FirstChild(); child; child = child->NextSibling()) {
if (const tinyxml2::XMLElement* childElem = child->ToElement()) {
// Recursively clone child elements with decremented depth
auto* clonedChild = CloneElementDeep(childElem, dstDoc, maxDepth - 1);
if (clonedChild) dst->InsertEndChild(clonedChild);
} else if (const tinyxml2::XMLText* txt = child->ToText()) {
auto* n = dstDoc.NewText(txt->Value());
dst->InsertEndChild(n);
} else if (const tinyxml2::XMLComment* c = child->ToComment()) {
auto* n = dstDoc.NewComment(c->Value());
dst->InsertEndChild(n);
}
}
return dst;
}
std::vector<Lxfml::Result> Lxfml::Split(const std::string_view data, const NiPoint3& curPosition) {
std::vector<Result> results;
// Handle empty or invalid input
if (data.empty()) {
return results;
}
// Prevent processing extremely large inputs that could cause hangs
if (data.size() > 10000000) { // 10MB limit
return results;
}
tinyxml2::XMLDocument doc;
// Use length-based parsing to avoid expensive string copy
const auto err = doc.Parse(data.data(), data.size());
if (err != tinyxml2::XML_SUCCESS) {
return results;
}
auto* lxfml = doc.FirstChildElement("LXFML");
if (!lxfml) {
return results;
}
// Build maps: partRef -> Part element, partRef -> Brick element, boneRef -> partRef, brickRef -> Brick element
std::unordered_map<std::string, tinyxml2::XMLElement*> partRefToPart;
std::unordered_map<std::string, tinyxml2::XMLElement*> partRefToBrick;
std::unordered_map<std::string, std::string> boneRefToPartRef;
std::unordered_map<std::string, tinyxml2::XMLElement*> brickByRef;
auto* bricksParent = lxfml->FirstChildElement("Bricks");
if (bricksParent) {
for (auto* brick = bricksParent->FirstChildElement("Brick"); brick; brick = brick->NextSiblingElement("Brick")) {
const char* brickRef = brick->Attribute("refID");
if (brickRef) brickByRef.emplace(std::string(brickRef), brick);
for (auto* part = brick->FirstChildElement("Part"); part; part = part->NextSiblingElement("Part")) {
const char* partRef = part->Attribute("refID");
if (partRef) {
partRefToPart.emplace(std::string(partRef), part);
partRefToBrick.emplace(std::string(partRef), brick);
}
auto* bone = part->FirstChildElement("Bone");
if (bone) {
const char* boneRef = bone->Attribute("refID");
if (boneRef) boneRefToPartRef.emplace(std::string(boneRef), partRef ? std::string(partRef) : std::string());
}
}
}
}
// Collect RigidSystem elements
std::vector<tinyxml2::XMLElement*> rigidSystems;
auto* rigidSystemsParent = lxfml->FirstChildElement("RigidSystems");
if (rigidSystemsParent) {
for (auto* rs = rigidSystemsParent->FirstChildElement("RigidSystem"); rs; rs = rs->NextSiblingElement("RigidSystem")) {
rigidSystems.push_back(rs);
}
}
// Collect top-level groups (immediate children of GroupSystem)
std::vector<tinyxml2::XMLElement*> groupRoots;
auto* groupSystemsParent = lxfml->FirstChildElement("GroupSystems");
if (groupSystemsParent) {
for (auto* gs = groupSystemsParent->FirstChildElement("GroupSystem"); gs; gs = gs->NextSiblingElement("GroupSystem")) {
for (auto* group = gs->FirstChildElement("Group"); group; group = group->NextSiblingElement("Group")) {
groupRoots.push_back(group);
}
}
}
// Track used bricks and rigidsystems
std::unordered_set<std::string> usedBrickRefs;
std::unordered_set<tinyxml2::XMLElement*> usedRigidSystems;
// Track used groups to avoid processing them twice
std::unordered_set<tinyxml2::XMLElement*> usedGroups;
// Helper to create output document from sets of brick refs and rigidsystem pointers
auto makeOutput = [&](const std::unordered_set<std::string>& bricksToInclude, const std::vector<tinyxml2::XMLElement*>& rigidSystemsToInclude, const std::vector<tinyxml2::XMLElement*>& groupsToInclude = {}) {
tinyxml2::XMLDocument outDoc;
outDoc.Parse(g_base.c_str());
auto* outRoot = outDoc.FirstChildElement("LXFML");
auto* outBricks = outRoot->FirstChildElement("Bricks");
auto* outRigidSystems = outRoot->FirstChildElement("RigidSystems");
auto* outGroupSystems = outRoot->FirstChildElement("GroupSystems");
// clone and insert bricks
for (const auto& bref : bricksToInclude) {
auto it = brickByRef.find(bref);
if (it == brickByRef.end()) continue;
tinyxml2::XMLElement* cloned = CloneElementDeep(it->second, outDoc);
if (cloned) outBricks->InsertEndChild(cloned);
}
// clone and insert rigidsystems
for (auto* rsPtr : rigidSystemsToInclude) {
tinyxml2::XMLElement* cloned = CloneElementDeep(rsPtr, outDoc);
if (cloned) outRigidSystems->InsertEndChild(cloned);
}
// clone and insert group(s) if requested
if (outGroupSystems && !groupsToInclude.empty()) {
// clear default children
while (outGroupSystems->FirstChild()) outGroupSystems->DeleteChild(outGroupSystems->FirstChild());
// create a GroupSystem element and append requested groups
auto* newGS = outDoc.NewElement("GroupSystem");
for (auto* gptr : groupsToInclude) {
tinyxml2::XMLElement* clonedG = CloneElementDeep(gptr, outDoc);
if (clonedG) newGS->InsertEndChild(clonedG);
}
outGroupSystems->InsertEndChild(newGS);
}
// Print to string
tinyxml2::XMLPrinter printer;
outDoc.Print(&printer);
// Normalize position and compute center using existing helper
std::string xmlString = printer.CStr();
if (xmlString.size() > 5000000) { // 5MB limit for normalization
Result emptyResult;
emptyResult.lxfml = xmlString;
return emptyResult;
}
auto normalized = NormalizePosition(xmlString, curPosition);
return normalized;
};
// 1) Process groups (each top-level Group becomes one output; nested groups are included)
for (auto* groupRoot : groupRoots) {
// Skip if this group was already processed as part of another group
if (usedGroups.find(groupRoot) != usedGroups.end()) continue;
// Helper to collect all partRefs in a group's subtree
std::function<void(const tinyxml2::XMLElement*, std::unordered_set<std::string>&)> collectParts = [&](const tinyxml2::XMLElement* g, std::unordered_set<std::string>& partRefs) {
if (!g) return;
const char* partAttr = g->Attribute("partRefs");
if (partAttr) {
for (auto& tok : GeneralUtils::SplitString(partAttr, ',')) partRefs.insert(tok);
}
for (auto* child = g->FirstChildElement("Group"); child; child = child->NextSiblingElement("Group")) collectParts(child, partRefs);
};
// Collect all groups that need to be merged into this output
std::vector<tinyxml2::XMLElement*> groupsToInclude{ groupRoot };
usedGroups.insert(groupRoot);
// Build initial sets of bricks and boneRefs from the starting group
std::unordered_set<std::string> partRefs;
collectParts(groupRoot, partRefs);
std::unordered_set<std::string> bricksIncluded;
std::unordered_set<std::string> boneRefsIncluded;
for (const auto& pref : partRefs) {
auto pit = partRefToBrick.find(pref);
if (pit != partRefToBrick.end()) {
const char* bref = pit->second->Attribute("refID");
if (bref) bricksIncluded.insert(std::string(bref));
}
auto partIt = partRefToPart.find(pref);
if (partIt != partRefToPart.end()) {
auto* bone = partIt->second->FirstChildElement("Bone");
if (bone) {
const char* bref = bone->Attribute("refID");
if (bref) boneRefsIncluded.insert(std::string(bref));
}
}
}
// Iteratively include any RigidSystems that reference any boneRefsIncluded
// and check if those rigid systems' bricks span other groups
bool changed = true;
std::vector<tinyxml2::XMLElement*> rigidSystemsToInclude;
int maxIterations = 1000; // Safety limit to prevent infinite loops
int iteration = 0;
while (changed && iteration < maxIterations) {
changed = false;
iteration++;
// First, expand rigid systems based on current boneRefsIncluded
for (auto* rs : rigidSystems) {
if (usedRigidSystems.find(rs) != usedRigidSystems.end()) continue;
// parse boneRefs of this rigid system (from its <Rigid> children)
bool intersects = false;
std::vector<std::string> rsBoneRefs;
for (auto* rigid = rs->FirstChildElement("Rigid"); rigid; rigid = rigid->NextSiblingElement("Rigid")) {
const char* battr = rigid->Attribute("boneRefs");
if (!battr) continue;
for (auto& tok : GeneralUtils::SplitString(battr, ',')) {
rsBoneRefs.push_back(tok);
if (boneRefsIncluded.find(tok) != boneRefsIncluded.end()) intersects = true;
}
}
if (!intersects) continue;
// include this rigid system and all boneRefs it references
usedRigidSystems.insert(rs);
rigidSystemsToInclude.push_back(rs);
for (const auto& br : rsBoneRefs) {
boneRefsIncluded.insert(br);
auto bpIt = boneRefToPartRef.find(br);
if (bpIt != boneRefToPartRef.end()) {
auto partRef = bpIt->second;
auto pbIt = partRefToBrick.find(partRef);
if (pbIt != partRefToBrick.end()) {
const char* bref = pbIt->second->Attribute("refID");
if (bref && bricksIncluded.insert(std::string(bref)).second) changed = true;
}
}
}
}
// Second, check if the newly included bricks span any other groups
// If so, merge those groups into the current output
for (auto* otherGroup : groupRoots) {
if (usedGroups.find(otherGroup) != usedGroups.end()) continue;
// Collect partRefs from this other group
std::unordered_set<std::string> otherPartRefs;
collectParts(otherGroup, otherPartRefs);
// Check if any of these partRefs correspond to bricks we've already included
bool spansOtherGroup = false;
for (const auto& pref : otherPartRefs) {
auto pit = partRefToBrick.find(pref);
if (pit != partRefToBrick.end()) {
const char* bref = pit->second->Attribute("refID");
if (bref && bricksIncluded.find(std::string(bref)) != bricksIncluded.end()) {
spansOtherGroup = true;
break;
}
}
}
if (spansOtherGroup) {
// Merge this group into the current output
usedGroups.insert(otherGroup);
groupsToInclude.push_back(otherGroup);
changed = true;
// Add all partRefs, boneRefs, and bricks from this group
for (const auto& pref : otherPartRefs) {
auto pit = partRefToBrick.find(pref);
if (pit != partRefToBrick.end()) {
const char* bref = pit->second->Attribute("refID");
if (bref) bricksIncluded.insert(std::string(bref));
}
auto partIt = partRefToPart.find(pref);
if (partIt != partRefToPart.end()) {
auto* bone = partIt->second->FirstChildElement("Bone");
if (bone) {
const char* bref = bone->Attribute("refID");
if (bref) boneRefsIncluded.insert(std::string(bref));
}
}
}
}
}
}
if (iteration >= maxIterations) {
// Iteration limit reached, stop processing to prevent infinite loops
// The file is likely malformed, so just skip further processing
return results;
}
// include bricks from bricksIncluded into used set
for (const auto& b : bricksIncluded) usedBrickRefs.insert(b);
// make output doc and push result (include all merged groups' XML)
auto normalized = makeOutput(bricksIncluded, rigidSystemsToInclude, groupsToInclude);
results.push_back(normalized);
}
// 2) Process remaining RigidSystems (each becomes its own file)
for (auto* rs : rigidSystems) {
if (usedRigidSystems.find(rs) != usedRigidSystems.end()) continue;
std::unordered_set<std::string> bricksIncluded;
// collect boneRefs referenced by this rigid system
for (auto* rigid = rs->FirstChildElement("Rigid"); rigid; rigid = rigid->NextSiblingElement("Rigid")) {
const char* battr = rigid->Attribute("boneRefs");
if (!battr) continue;
for (auto& tok : GeneralUtils::SplitString(battr, ',')) {
auto bpIt = boneRefToPartRef.find(tok);
if (bpIt != boneRefToPartRef.end()) {
auto partRef = bpIt->second;
auto pbIt = partRefToBrick.find(partRef);
if (pbIt != partRefToBrick.end()) {
const char* bref = pbIt->second->Attribute("refID");
if (bref) bricksIncluded.insert(std::string(bref));
}
}
}
}
// mark used
for (const auto& b : bricksIncluded) usedBrickRefs.insert(b);
usedRigidSystems.insert(rs);
std::vector<tinyxml2::XMLElement*> rsVec{ rs };
auto normalized = makeOutput(bricksIncluded, rsVec);
results.push_back(normalized);
}
// 3) Any remaining bricks not included become their own files
for (const auto& [bref, brickPtr] : brickByRef) {
if (usedBrickRefs.find(bref) != usedBrickRefs.end()) continue;
std::unordered_set<std::string> bricksIncluded{ bref };
auto normalized = makeOutput(bricksIncluded, {});
results.push_back(normalized);
usedBrickRefs.insert(bref);
}
return results;
}

View File

@@ -6,6 +6,7 @@
#include <string>
#include <string_view>
#include <vector>
#include "NiPoint3.h"
@@ -18,6 +19,7 @@ namespace Lxfml {
// Normalizes a LXFML model to be positioned relative to its local 0, 0, 0 rather than a game worlds 0, 0, 0.
// Returns a struct of its new center and the updated LXFML containing these edits.
[[nodiscard]] Result NormalizePosition(const std::string_view data, const NiPoint3& curPosition = NiPoint3Constant::ZERO);
[[nodiscard]] std::vector<Result> Split(const std::string_view data, const NiPoint3& curPosition = NiPoint3Constant::ZERO);
// these are only for the migrations due to a bug in one of the implementations.
[[nodiscard]] Result NormalizePositionOnlyFirstPart(const std::string_view data);

View File

@@ -53,16 +53,21 @@ Lxfml::Result Lxfml::NormalizePositionOnlyFirstPart(const std::string_view data)
continue;
}
auto x = GeneralUtils::TryParse<float>(split[9]).value();
auto y = GeneralUtils::TryParse<float>(split[10]).value();
auto z = GeneralUtils::TryParse<float>(split[11]).value();
if (x < lowest.x) lowest.x = x;
if (y < lowest.y) lowest.y = y;
if (z < lowest.z) lowest.z = z;
try {
auto x = GeneralUtils::TryParse<float>(split[9]).value();
auto y = GeneralUtils::TryParse<float>(split[10]).value();
auto z = GeneralUtils::TryParse<float>(split[11]).value();
if (x < lowest.x) lowest.x = x;
if (y < lowest.y) lowest.y = y;
if (z < lowest.z) lowest.z = z;
if (highest.x < x) highest.x = x;
if (highest.y < y) highest.y = y;
if (highest.z < z) highest.z = z;
if (highest.x < x) highest.x = x;
if (highest.y < y) highest.y = y;
if (highest.z < z) highest.z = z;
} catch (std::exception& e) {
LOG("Failed to parse a split value of either (%s), (%s), or (%s).", split[9].c_str(), split[10].c_str(), split[11].c_str());
return toReturn; // Early return since we failed to parse this lxfml.
}
}
auto delta = (highest - lowest) / 2.0f;

View File

@@ -1,105 +1,77 @@
#include "Metrics.hpp"
#include "Metrics.h"
#include "StringifiedEnum.h"
#include <chrono>
std::unordered_map<MetricVariable, Metric*> Metrics::m_Metrics = {};
std::vector<MetricVariable> Metrics::m_Variables = {
MetricVariable::GameLoop,
MetricVariable::PacketHandling,
MetricVariable::UpdateEntities,
MetricVariable::UpdateSpawners,
MetricVariable::Physics,
MetricVariable::UpdateReplica,
MetricVariable::Ghosting,
MetricVariable::CPUTime,
MetricVariable::Sleep,
MetricVariable::Frame,
};
namespace {
std::unordered_map<MetricVariable, Metric> g_Metrics = {};
std::vector<MetricVariable> g_Variables = {
MetricVariable::GameLoop,
MetricVariable::PacketHandling,
MetricVariable::UpdateEntities,
MetricVariable::UpdateSpawners,
MetricVariable::Physics,
MetricVariable::UpdateReplica,
MetricVariable::Ghosting,
MetricVariable::CPUTime,
MetricVariable::Sleep,
MetricVariable::Frame,
};
}
void Metrics::AddMeasurement(MetricVariable variable, int64_t value) {
const auto& iter = m_Metrics.find(variable);
Metric* metric;
if (iter == m_Metrics.end()) {
metric = new Metric();
m_Metrics[variable] = metric;
} else {
metric = iter->second;
}
auto& metric = g_Metrics[variable];
AddMeasurement(metric, value);
}
void Metrics::AddMeasurement(Metric* metric, int64_t value) {
const auto index = metric->measurementIndex;
void Metrics::AddMeasurement(Metric& metric, int64_t value) {
const auto index = metric.measurementIndex;
metric->measurements[index] = value;
metric.measurements[index] = value;
if (metric->max == -1 || value > metric->max) {
metric->max = value;
} else if (metric->min == -1 || metric->min > value) {
metric->min = value;
if (metric.max == -1 || value > metric.max) {
metric.max = value;
} else if (metric.min == -1 || metric.min > value) {
metric.min = value;
}
if (metric->measurementSize < MAX_MEASURMENT_POINTS) {
metric->measurementSize++;
if (metric.measurementSize < MAX_MEASURMENT_POINTS) {
metric.measurementSize++;
}
metric->measurementIndex = (index + 1) % MAX_MEASURMENT_POINTS;
metric.measurementIndex = (index + 1) % MAX_MEASURMENT_POINTS;
}
const Metric* Metrics::GetMetric(MetricVariable variable) {
const auto& iter = m_Metrics.find(variable);
if (iter == m_Metrics.end()) {
return nullptr;
}
Metric* metric = iter->second;
const Metric& Metrics::GetMetric(MetricVariable variable) {
auto& metric = g_Metrics[variable];
int64_t average = 0;
for (size_t i = 0; i < metric->measurementSize; i++) {
average += metric->measurements[i];
for (size_t i = 0; i < metric.measurementSize; i++) {
average += metric.measurements[i];
}
average /= metric->measurementSize;
average /= metric.measurementSize;
metric->average = average;
metric.average = average;
return metric;
}
void Metrics::StartMeasurement(MetricVariable variable) {
const auto& iter = m_Metrics.find(variable);
auto& metric = g_Metrics[variable];
Metric* metric;
if (iter == m_Metrics.end()) {
metric = new Metric();
m_Metrics[variable] = metric;
} else {
metric = iter->second;
}
metric->activeMeasurement = std::chrono::high_resolution_clock::now();
metric.activeMeasurement = std::chrono::high_resolution_clock::now();
}
void Metrics::EndMeasurement(MetricVariable variable) {
const auto end = std::chrono::high_resolution_clock::now();
const auto& iter = m_Metrics.find(variable);
auto& metric = g_Metrics[variable];
if (iter == m_Metrics.end()) {
return;
}
Metric* metric = iter->second;
const auto elapsed = end - metric->activeMeasurement;
const auto elapsed = end - metric.activeMeasurement;
const auto nanoseconds = std::chrono::duration_cast<std::chrono::nanoseconds>(elapsed).count();
@@ -110,44 +82,12 @@ float Metrics::ToMiliseconds(int64_t nanoseconds) {
return static_cast<float>(nanoseconds) / 1e6;
}
std::string Metrics::MetricVariableToString(MetricVariable variable) {
switch (variable) {
case MetricVariable::GameLoop:
return "GameLoop";
case MetricVariable::PacketHandling:
return "PacketHandling";
case MetricVariable::UpdateEntities:
return "UpdateEntities";
case MetricVariable::UpdateSpawners:
return "UpdateSpawners";
case MetricVariable::Physics:
return "Physics";
case MetricVariable::UpdateReplica:
return "UpdateReplica";
case MetricVariable::Sleep:
return "Sleep";
case MetricVariable::CPUTime:
return "CPUTime";
case MetricVariable::Frame:
return "Frame";
case MetricVariable::Ghosting:
return "Ghosting";
default:
return "Invalid";
}
const std::string_view Metrics::MetricVariableToString(MetricVariable variable) {
return StringifiedEnum::ToString(variable);
}
const std::vector<MetricVariable>& Metrics::GetAllMetrics() {
return m_Variables;
}
void Metrics::Clear() {
for (const auto& pair : m_Metrics) {
delete pair.second;
}
m_Metrics.clear();
return g_Variables;
}
/* RSS Memory utilities

48
dCommon/Metrics.h Normal file
View File

@@ -0,0 +1,48 @@
#pragma once
#include "dCommonVars.h"
#include <vector>
#include <map>
#include <string_view>
#include <unordered_map>
#include <chrono>
#define MAX_MEASURMENT_POINTS 1024
enum class MetricVariable : int32_t {
GameLoop,
PacketHandling,
UpdateEntities,
UpdateSpawners,
Physics,
UpdateReplica,
Ghosting,
CPUTime,
Sleep,
Frame,
};
struct Metric {
int64_t measurements[MAX_MEASURMENT_POINTS] = {};
size_t measurementIndex = 0;
size_t measurementSize = 0;
int64_t max = -1;
int64_t min = -1;
int64_t average = 0;
std::chrono::time_point<std::chrono::high_resolution_clock> activeMeasurement;
};
namespace Metrics {
void AddMeasurement(MetricVariable variable, int64_t value);
void AddMeasurement(Metric& metric, int64_t value);
const Metric& GetMetric(MetricVariable variable);
void StartMeasurement(MetricVariable variable);
void EndMeasurement(MetricVariable variable);
float ToMiliseconds(int64_t nanoseconds);
const std::string_view MetricVariableToString(MetricVariable variable);
const std::vector<MetricVariable>& GetAllMetrics();
size_t GetPeakRSS();
size_t GetCurrentRSS();
size_t GetProcessID();
};

View File

@@ -1,61 +0,0 @@
#pragma once
#include "dCommonVars.h"
#include <vector>
#include <map>
#include <unordered_map>
#include <chrono>
#define MAX_MEASURMENT_POINTS 1024
enum class MetricVariable : int32_t
{
GameLoop,
PacketHandling,
UpdateEntities,
UpdateSpawners,
Physics,
UpdateReplica,
Ghosting,
CPUTime,
Sleep,
Frame,
};
struct Metric
{
int64_t measurements[MAX_MEASURMENT_POINTS] = {};
size_t measurementIndex = 0;
size_t measurementSize = 0;
int64_t max = -1;
int64_t min = -1;
int64_t average = 0;
std::chrono::time_point<std::chrono::high_resolution_clock> activeMeasurement;
};
class Metrics
{
public:
~Metrics();
static void AddMeasurement(MetricVariable variable, int64_t value);
static void AddMeasurement(Metric* metric, int64_t value);
static const Metric* GetMetric(MetricVariable variable);
static void StartMeasurement(MetricVariable variable);
static void EndMeasurement(MetricVariable variable);
static float ToMiliseconds(int64_t nanoseconds);
static std::string MetricVariableToString(MetricVariable variable);
static const std::vector<MetricVariable>& GetAllMetrics();
static size_t GetPeakRSS();
static size_t GetCurrentRSS();
static size_t GetProcessID();
static void Clear();
private:
Metrics();
static std::unordered_map<MetricVariable, Metric*> m_Metrics;
static std::vector<MetricVariable> m_Variables;
};

View File

@@ -1,6 +1,8 @@
#ifndef __NIPOINT3_H__
#define __NIPOINT3_H__
#ifndef GLM_ENABLE_EXPERIMENTAL
# define GLM_ENABLE_EXPERIMENTAL
#endif
/*!
\file NiPoint3.hpp
\brief Defines a point in space in XYZ coordinates

View File

@@ -1,6 +1,8 @@
#ifndef NIQUATERNION_H
#define NIQUATERNION_H
#ifndef GLM_ENABLE_EXPERIMENTAL
# define GLM_ENABLE_EXPERIMENTAL
#endif
// Custom Classes
#include "NiPoint3.h"

View File

@@ -45,6 +45,12 @@ Sd0::Sd0(std::istream& buffer) {
uint32_t bufferSize = buffer.tellg();
buffer.seekg(0, std::ios::beg);
WriteSize(firstChunk, bufferSize);
// its expected that if we got here, we got an old sd0 buffer where we ignored the sd0 part
// that means this can be at most the compressed chunk limit.
if (bufferSize > MAX_UNCOMPRESSED_CHUNK_SIZE) {
LOG("Possible bad chunk size of %i specified, rejecting.", bufferSize);
return;
}
firstChunk.resize(firstChunk.size() + bufferSize);
auto* dataStart = reinterpret_cast<char*>(firstChunk.data() + GetDataOffset(true));
if (!buffer.read(dataStart, bufferSize)) {
@@ -71,6 +77,12 @@ Sd0::Sd0(std::istream& buffer) {
WriteSize(chunk, chunkSize);
// Assuming a good buffer that is large enough to take up 2 zlib buffers
// any buffer should be compressed enough to take up less size than its uncompressed counterpart
if (chunkSize > MAX_UNCOMPRESSED_CHUNK_SIZE) {
LOG("Possible bad chunk size of %i specified, rejecting.", chunkSize);
break;
}
chunk.resize(chunkSize + dataOffset);
auto* dataStart = reinterpret_cast<char*>(chunk.data() + dataOffset);
if (!buffer.read(dataStart, chunkSize)) {
@@ -95,6 +107,11 @@ void Sd0::FromData(const uint8_t* data, size_t bufferSize) {
startOffset, numToCopy,
compressedChunk.data(), compressedChunk.size());
if (compressedSize == -1) {
LOG("Failed to compress chunk, aborting");
break;
}
auto& chunk = m_Chunks.emplace_back();
bool firstBuffer = m_Chunks.size() == 1;
auto dataOffset = GetDataOffset(firstBuffer);
@@ -119,6 +136,12 @@ std::string Sd0::GetAsStringUncompressed() const {
auto dataOffset = GetDataOffset(first);
first = false;
const auto chunkSize = chunk.size();
if (chunkSize <= static_cast<size_t>(dataOffset)) {
LOG("Bad chunkSize for data, aborting");
toReturn = "";
totalSize = 0;
break;
}
auto oldSize = toReturn.size();
toReturn.resize(oldSize + MAX_UNCOMPRESSED_CHUNK_SIZE);
@@ -128,6 +151,13 @@ std::string Sd0::GetAsStringUncompressed() const {
reinterpret_cast<uint8_t*>(toReturn.data()) + oldSize, MAX_UNCOMPRESSED_CHUNK_SIZE,
error);
if (uncompressedSize == -1) {
LOG("Failed to decompress chunk, aborting");
toReturn = "";
totalSize = 0;
break;
}
totalSize += uncompressedSize;
}

View File

@@ -3,12 +3,12 @@
#include "zlib.h"
namespace ZCompression {
int32_t GetMaxCompressedLength(int32_t nLenSrc) {
int32_t n16kBlocks = (nLenSrc + 16383) / 16384; // round up any fraction of a block
uint32_t GetMaxCompressedLength(uint32_t nLenSrc) {
uint32_t n16kBlocks = (nLenSrc + 16383) / 16384; // round up any fraction of a block
return (nLenSrc + 6 + (n16kBlocks * 5));
}
int32_t Compress(const uint8_t* abSrc, int32_t nLenSrc, uint8_t* abDst, int32_t nLenDst) {
int32_t Compress(const uint8_t* abSrc, uint32_t nLenSrc, uint8_t* abDst, uint32_t nLenDst) {
z_stream zInfo = { 0 };
zInfo.total_in = zInfo.avail_in = nLenSrc;
zInfo.total_out = zInfo.avail_out = nLenDst;
@@ -27,7 +27,7 @@ namespace ZCompression {
return(nRet);
}
int32_t Decompress(const uint8_t* abSrc, int32_t nLenSrc, uint8_t* abDst, int32_t nLenDst, int32_t& nErr) {
int32_t Decompress(const uint8_t* abSrc, uint32_t nLenSrc, uint8_t* abDst, uint32_t nLenDst, int32_t& nErr) {
// Get the size of the decompressed data
z_stream zInfo = { 0 };
zInfo.total_in = zInfo.avail_in = nLenSrc;

View File

@@ -3,10 +3,10 @@
#include <cstdint>
namespace ZCompression {
int32_t GetMaxCompressedLength(int32_t nLenSrc);
uint32_t GetMaxCompressedLength(uint32_t nLenSrc);
int32_t Compress(const uint8_t* abSrc, int32_t nLenSrc, uint8_t* abDst, int32_t nLenDst);
int32_t Compress(const uint8_t* abSrc, uint32_t nLenSrc, uint8_t* abDst, uint32_t nLenDst);
int32_t Decompress(const uint8_t* abSrc, int32_t nLenSrc, uint8_t* abDst, int32_t nLenDst, int32_t& nErr);
int32_t Decompress(const uint8_t* abSrc, uint32_t nLenSrc, uint8_t* abDst, uint32_t nLenDst, int32_t& nErr);
}

View File

@@ -7,7 +7,7 @@
#include "zlib.h"
constexpr uint32_t CRC32_INIT = 0xFFFFFFFF;
constexpr auto NULL_TERMINATOR = std::string_view{"\0\0\0", 4};
constexpr auto NULL_TERMINATOR = std::string_view{ "\0\0\0", 4 };
AssetManager::AssetManager(const std::filesystem::path& path) {
if (!std::filesystem::is_directory(path)) {
@@ -25,7 +25,7 @@ AssetManager::AssetManager(const std::filesystem::path& path) {
if (!std::filesystem::exists(m_Path / ".." / "versions")) {
throw std::runtime_error("No \"versions\" directory found in the parent directories of \"res\" - packed asset bundle cannot be loaded.");
}
m_AssetBundleType = eAssetBundleType::Packed;
m_RootPath = (m_Path / "..");
@@ -34,7 +34,7 @@ AssetManager::AssetManager(const std::filesystem::path& path) {
if (!std::filesystem::exists(m_Path / ".." / ".." / "versions")) {
throw std::runtime_error("No \"versions\" directory found in the parent directories of \"res\" - packed asset bundle cannot be loaded.");
}
m_AssetBundleType = eAssetBundleType::Packed;
m_RootPath = (m_Path / ".." / "..");
@@ -54,15 +54,15 @@ AssetManager::AssetManager(const std::filesystem::path& path) {
}
switch (m_AssetBundleType) {
case eAssetBundleType::Packed: {
this->LoadPackIndex();
break;
}
case eAssetBundleType::None:
[[fallthrough]];
case eAssetBundleType::Unpacked: {
break;
}
case eAssetBundleType::Packed: {
this->LoadPackIndex();
break;
}
case eAssetBundleType::None:
[[fallthrough]];
case eAssetBundleType::Unpacked: {
break;
}
}
}
@@ -79,7 +79,7 @@ bool AssetManager::HasFile(std::string fixedName) const {
std::replace(fixedName.begin(), fixedName.end(), '\\', '/');
if (std::filesystem::exists(m_ResPath / fixedName)) return true;
if (this->m_AssetBundleType == eAssetBundleType::Unpacked) return false;
if (this->m_AssetBundleType == eAssetBundleType::Unpacked || !m_PackIndex) return false;
std::replace(fixedName.begin(), fixedName.end(), '/', '\\');
if (fixedName.rfind("client\\res\\", 0) != 0) fixedName = "client\\res\\" + fixedName;
@@ -145,8 +145,12 @@ bool AssetManager::GetFile(std::string fixedName, char** data, uint32_t* len) co
}
const auto& pack = this->m_PackIndex->GetPacks().at(packIndex);
const bool success = pack.ReadFileFromPack(crc, data, len);
bool success = false;
try {
success = pack.ReadFileFromPack(crc, data, len);
} catch (std::exception& e) {
LOG("Failed to read file %s from pack file", fixedName.c_str());
}
return success;
}

View File

@@ -81,6 +81,9 @@ public:
[[nodiscard]]
AssetStream GetFile(const char* name) const;
[[nodiscard]]
AssetStream GetFile(const std::string& name) const { return GetFile(name.c_str()); };
private:
void LoadPackIndex();

View File

@@ -46,6 +46,7 @@ bool Pack::HasFile(const uint32_t crc) const {
}
bool Pack::ReadFileFromPack(const uint32_t crc, char** data, uint32_t* len) const {
const auto pathStr = m_FilePath.string();
// Time for some wacky C file reading for speed reasons
PackRecord pkRecord{};
@@ -65,16 +66,21 @@ bool Pack::ReadFileFromPack(const uint32_t crc, char** data, uint32_t* len) cons
bool isCompressed = (pkRecord.m_IsCompressed & 0xff) > 0;
auto inPackSize = isCompressed ? pkRecord.m_CompressedSize : pkRecord.m_UncompressedSize;
FILE* file;
FILE* file = nullptr;
#ifdef _WIN32
fopen_s(&file, m_FilePath.string().c_str(), "rb");
fopen_s(&file, pathStr.c_str(), "rb");
#elif __APPLE__
// macOS has 64bit file IO by default
file = fopen(m_FilePath.string().c_str(), "rb");
file = fopen(pathStr.c_str(), "rb");
#else
file = fopen64(m_FilePath.string().c_str(), "rb");
file = fopen64(pathStr.c_str(), "rb");
#endif
if (!file) {
LOG("No file found for path %s", pathStr.c_str());
throw std::runtime_error("Could not find file " + pathStr);
}
fseek(file, pos, SEEK_SET);
if (!isCompressed) {
@@ -102,14 +108,18 @@ bool Pack::ReadFileFromPack(const uint32_t crc, char** data, uint32_t* len) cons
int32_t readInData = fread(&size, sizeof(uint32_t), 1, file);
pos += 4; // Move pointer position 4 to the right
char* chunk = static_cast<char*>(malloc(size));
int32_t readInData2 = fread(chunk, sizeof(int8_t), size, file);
std::unique_ptr<char[]> chunk(new char[size]);
int32_t readInData2 = fread(chunk.get(), sizeof(int8_t), size, file);
pos += size; // Move pointer position the amount of bytes read to the right
int32_t err;
currentReadPos += ZCompression::Decompress(reinterpret_cast<uint8_t*>(chunk), size, reinterpret_cast<uint8_t*>(decompressedData + currentReadPos), Sd0::MAX_UNCOMPRESSED_CHUNK_SIZE, err);
const auto countToRead = ZCompression::Decompress(reinterpret_cast<uint8_t*>(chunk.get()), size, reinterpret_cast<uint8_t*>(decompressedData + currentReadPos), Sd0::MAX_UNCOMPRESSED_CHUNK_SIZE, err);
if (countToRead == -1) {
LOG("Error decompressing zlib data from file %s", pathStr.c_str());
throw std::runtime_error("Error decompressing zlib data from file " + pathStr);
}
currentReadPos += countToRead;
free(chunk);
}
*data = decompressedData;

View File

@@ -47,6 +47,8 @@ void dConfig::LoadConfig() {
void dConfig::ReloadConfig() {
this->m_ConfigValues.clear();
LoadConfig();
for (const auto& handler : m_ConfigHandlers) handler();
LogSettings();
}
const std::string& dConfig::GetValue(std::string key) {
@@ -58,6 +60,18 @@ const std::string& dConfig::GetValue(std::string key) {
return this->m_ConfigValues[key];
}
void dConfig::AddConfigHandler(std::function<void()> handler) {
m_ConfigHandlers.push_back(handler);
}
void dConfig::LogSettings() const {
LOG("Configuration settings:");
for (const auto& [key, value] : m_ConfigValues) {
const auto& valueLog = key.find("password") != std::string::npos ? "<HIDDEN>" : value;
LOG(" %s = %s", key.c_str(), valueLog.c_str());
}
}
void dConfig::ProcessLine(const std::string& line) {
auto splitLoc = line.find('=');
auto key = line.substr(0, splitLoc);
@@ -70,3 +84,7 @@ void dConfig::ProcessLine(const std::string& line) {
this->m_ConfigValues.insert(std::make_pair(key, value));
}
std::string dConfig::GetValue(const std::string& key, const char* emptyValue) {
return GetValue(key, std::string(emptyValue));
};

View File

@@ -1,8 +1,12 @@
#pragma once
#include <fstream>
#include <functional>
#include <map>
#include <string>
#include "GeneralUtils.h"
class dConfig {
public:
dConfig(const std::string& filepath);
@@ -20,6 +24,14 @@ public:
*/
const std::string& GetValue(std::string key);
// Gets a value from the config and returns the parsed value, or the default value should parsing have failed.
template<typename T>
T GetValue(const std::string& key, const T emptyValue = T()) {
return GeneralUtils::TryParse<T>(GetValue(key)).value_or(emptyValue);
}
std::string GetValue(const std::string& key, const char* emptyValue);
/**
* Loads the config from a file
*/
@@ -29,10 +41,21 @@ public:
* Reloads the config file to reset values
*/
void ReloadConfig();
// Adds a function to be called when the config is (re)loaded
void AddConfigHandler(std::function<void()> handler);
void LogSettings() const;
private:
void ProcessLine(const std::string& line);
private:
std::map<std::string, std::string> m_ConfigValues;
std::vector<std::function<void()>> m_ConfigHandlers;
std::string m_ConfigFilePath;
};
template<>
inline std::string dConfig::GetValue(const std::string& key, const std::string emptyValue) {
const auto& value = GetValue(key);
return value.empty() ? emptyValue : value;
};

View File

@@ -3,9 +3,7 @@
namespace MessageType {
enum class Master : uint32_t {
REQUEST_PERSISTENT_ID = 1,
REQUEST_PERSISTENT_ID_RESPONSE,
REQUEST_ZONE_TRANSFER,
REQUEST_ZONE_TRANSFER = 1,
REQUEST_ZONE_TRANSFER_RESPONSE,
SERVER_INFO,
REQUEST_SESSION_KEY,
@@ -29,6 +27,8 @@ namespace MessageType {
AFFIRM_TRANSFER_REQUEST,
AFFIRM_TRANSFER_RESPONSE,
NEW_SESSION_ALERT
NEW_SESSION_ALERT,
REQUEST_SERVER_LIST
};
}

View File

@@ -5,7 +5,8 @@ enum class ServiceType : uint16_t {
COMMON = 0,
AUTH,
CHAT,
WORLD = 4,
DASHBOARD,
WORLD,
CLIENT,
MASTER,
UNKNOWN

View File

@@ -16,8 +16,8 @@
// These are the same define, but they mean two different things in different contexts
// so a different define to distinguish what calculation is happening will help clarity.
#define FRAMES_TO_MS(x) 1000 / x
#define MS_TO_FRAMES(x) 1000 / x
#define FRAMES_TO_MS(x) (1000 / (x))
#define MS_TO_FRAMES(x) (1000 / (x))
//=========== FRAME TIMINGS ===========
constexpr uint32_t highFramerate = 60;
@@ -58,6 +58,7 @@ constexpr LWOCLONEID LWOCLONEID_INVALID = -1; //!< Invalid LWOCLONEID
constexpr LWOINSTANCEID LWOINSTANCEID_INVALID = -1; //!< Invalid LWOINSTANCEID
constexpr LWOMAPID LWOMAPID_INVALID = -1; //!< Invalid LWOMAPID
constexpr uint64_t LWOZONEID_INVALID = 0; //!< Invalid LWOZONEID
constexpr uint32_t MAX_MESSAGE_LENGTH = 0x500000; //!< Prevent exceptionally large msgs from being processed. Should always be used to check user provided inputs.
constexpr float PI = 3.14159f;
@@ -110,18 +111,6 @@ private:
constexpr LWOSCENEID LWOSCENEID_INVALID = -1;
struct LWONameValue {
uint32_t length = 0; //!< The length of the name
std::u16string name; //!< The name
LWONameValue() = default;
LWONameValue(const std::u16string& name) {
this->name = name;
this->length = static_cast<uint32_t>(name.length());
}
};
struct FriendData {
public:
bool isOnline = false;

View File

@@ -18,7 +18,10 @@ enum class eCharacterVersion : uint32_t {
SPEED_BASE,
// Fixes nexus force explorer missions
NJ_JAYMISSIONS,
UP_TO_DATE, // will become NEXUS_FORCE_EXPLORER
NEXUS_FORCE_EXPLORER, // Fixes pet ids in player inventories
PET_IDS, // Fixes pet ids in player inventories
INVENTORY_PERSISTENT_IDS, // Fixes racing meta missions
UP_TO_DATE, // will become RACING_META_MISSIONS
};
#endif //!__ECHARACTERVERSION__H__

View File

@@ -50,7 +50,10 @@ enum class eMissionState : int {
/**
* The mission has been completed before and has now been completed again. Used for daily missions.
*/
COMPLETE_READY_TO_COMPLETE = 12
COMPLETE_READY_TO_COMPLETE = 12,
// The mission is failed (don't know where this is used)
FAILED = 16,
};
#endif //!__MISSIONSTATE__H__

View File

@@ -1,13 +1,12 @@
#ifndef __EOBJECTBITS__H__
#define __EOBJECTBITS__H__
#ifndef EOBJECTBITS_H
#define EOBJECTBITS_H
#include <cstdint>
enum class eObjectBits : size_t {
PERSISTENT = 32,
CLIENT = 46,
SPAWNED = 58,
CHARACTER = 60
};
#endif //!__EOBJECTBITS__H__
#endif //!EOBJECTBITS_H

View File

@@ -0,0 +1,58 @@
set(DDASHBOARDSERVER_SOURCES
"DashboardServer.cpp"
)
add_subdirectory(routes)
add_subdirectory(auth)
add_executable(DashboardServer ${DDASHBOARDSERVER_SOURCES})
target_include_directories(DashboardServer PRIVATE
"${PROJECT_SOURCE_DIR}/dCommon"
"${PROJECT_SOURCE_DIR}/dCommon/dClient"
"${PROJECT_SOURCE_DIR}/dCommon/dEnums"
"${PROJECT_SOURCE_DIR}/dDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase/CDClientTables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/ITables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/MySQL"
"${PROJECT_SOURCE_DIR}/dNet"
"${PROJECT_SOURCE_DIR}/dWeb"
"${PROJECT_SOURCE_DIR}/dServer"
"${PROJECT_SOURCE_DIR}/thirdparty"
"${PROJECT_SOURCE_DIR}/thirdparty/nlohmann"
"${PROJECT_SOURCE_DIR}/dDashboardServer"
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes"
)
target_link_libraries(DashboardServer ${COMMON_LIBRARIES} dWeb dServer bcrypt OpenSSL::Crypto DashboardRoutes DashboardAuth)
# Copy static files and templates to build directory (always copy)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E remove_directory
${CMAKE_BINARY_DIR}/dDashboardServer/static
COMMENT "Removing old static files"
)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_CURRENT_SOURCE_DIR}/static
${CMAKE_BINARY_DIR}/dDashboardServer/static
COMMENT "Copying DashboardServer static files"
)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E remove_directory
${CMAKE_BINARY_DIR}/dDashboardServer/templates
COMMENT "Removing old templates"
)
add_custom_command(TARGET DashboardServer POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_CURRENT_SOURCE_DIR}/templates
${CMAKE_BINARY_DIR}/dDashboardServer/templates
COMMENT "Copying DashboardServer templates"
)

View File

@@ -0,0 +1,211 @@
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <thread>
#include <csignal>
#include <memory>
#include "CDClientDatabase.h"
#include "CDClientManager.h"
#include "Database.h"
#include "dConfig.h"
#include "Logger.h"
#include "dServer.h"
#include "AssetManager.h"
#include "BinaryPathFinder.h"
#include "ServiceType.h"
#include "MessageType/Master.h"
#include "Game.h"
#include "BitStreamUtils.h"
#include "Diagnostics.h"
#include "Web.h"
#include "Server.h"
#include "ServerState.h"
#include "APIRoutes.h"
#include "StaticRoutes.h"
#include "DashboardRoutes.h"
#include "WSRoutes.h"
#include "AuthRoutes.h"
#include "AuthMiddleware.h"
#include "DashboardAuthService.h"
#include "AuthTokenHandler.h"
namespace Game {
Logger* logger = nullptr;
dServer* server = nullptr;
dConfig* config = nullptr;
Game::signal_t lastSignal = 0;
std::mt19937 randomEngine;
}
// Define global server state
namespace ServerState {
ServerStatus g_AuthStatus{};
ServerStatus g_ChatStatus{};
std::vector<WorldInstanceInfo> g_WorldInstances{};
std::mutex g_StatusMutex{};
}
namespace {
dServer* g_Server = nullptr;
bool g_RequestedServerList = false;
}
int main(int argc, char** argv) {
Diagnostics::SetProduceMemoryDump(true);
std::signal(SIGINT, Game::OnSignal);
std::signal(SIGTERM, Game::OnSignal);
uint32_t maxClients = 999;
uint32_t ourPort = 2006;
std::string ourIP = "127.0.0.1";
// Read config
Game::config = new dConfig("dashboardconfig.ini");
// Setup logger
Server::SetupLogger("DashboardServer");
if (!Game::logger) return EXIT_FAILURE;
Game::config->LogSettings();
LOG("Starting Dashboard Server");
// Load settings
if (Game::config->GetValue("max_clients") != "")
maxClients = std::stoi(Game::config->GetValue("max_clients"));
if (Game::config->GetValue("port") != "")
ourPort = std::atoi(Game::config->GetValue("port").c_str());
if (Game::config->GetValue("listen_ip") != "")
ourIP = Game::config->GetValue("listen_ip");
// Connect to CDClient database
try {
const std::string cdclientPath = BinaryPathFinder::GetBinaryDir() / "resServer/CDServer.sqlite";
CDClientDatabase::Connect(cdclientPath);
} catch (std::exception& ex) {
LOG("Failed to connect to CDClient database: %s", ex.what());
return EXIT_FAILURE;
}
// Connect to the database
try {
Database::Connect();
} catch (std::exception& ex) {
LOG("Failed to connect to the database: %s", ex.what());
return EXIT_FAILURE;
}
// Get master info from database
std::string masterIP = "localhost";
uint32_t masterPort = 1000;
std::string masterPassword;
auto masterInfo = Database::Get()->GetMasterInfo();
if (masterInfo) {
masterIP = masterInfo->ip;
masterPort = masterInfo->port;
masterPassword = masterInfo->password;
}
// Setup network server for communicating with Master
g_Server = new dServer(
masterIP,
ourPort,
0,
maxClients,
false,
false,
Game::logger,
masterIP,
masterPort,
ServiceType::DASHBOARD, // Connect as dashboard to master
Game::config,
&Game::lastSignal,
masterPassword
);
// Initialize web server
if (!Game::web.Startup(ourIP, ourPort)) {
LOG("Failed to start web server on %s:%d", ourIP.c_str(), ourPort);
return EXIT_FAILURE;
}
// Set up WebSocket authentication callback using consolidated handler
Game::web.SetWSAuthCallback([](const std::string& token) -> bool {
auto result = AuthTokenHandler::ValidateToken(token);
return result.isValid;
});
// Register global middleware
Game::web.AddGlobalMiddleware(std::make_shared<AuthMiddleware>());
// Register routes in order: API, Static, Auth, WebSocket, Dashboard (dashboard MUST be last)
RegisterAPIRoutes();
RegisterStaticRoutes();
RegisterAuthRoutes();
RegisterWSRoutes();
RegisterDashboardRoutes(); // Must be last - catches all unmatched routes
LOG("Dashboard Server started successfully on %s:%d", ourIP.c_str(), ourPort);
LOG("Connected to Master Server at %s:%d", masterIP.c_str(), masterPort);
// Main loop
auto lastTime = std::chrono::high_resolution_clock::now();
auto lastBroadcast = lastTime;
auto currentTime = lastTime;
constexpr float deltaTime = 1.0f / 60.0f; // 60 FPS
constexpr float broadcastInterval = 2000.0f; // Broadcast every 2 seconds
while (!Game::ShouldShutdown()) {
currentTime = std::chrono::high_resolution_clock::now();
const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(currentTime - lastTime).count();
const auto elapsedSinceBroadcast = std::chrono::duration_cast<std::chrono::milliseconds>(currentTime - lastBroadcast).count();
if (elapsed >= 1000.0f / 60.0f) {
// // Handle master server packets
// Packet* packet = g_Server->ReceiveFromMaster();
// if (packet) {
// RakNet::BitStream bitStream(packet->data, packet->length, false);
// PacketHandler::HandlePacket(bitStream, packet->systemAddress);
// g_Server->DeallocateMasterPacket(packet);
// }
// // Handle RakNet protocol packets from connected servers
// packet = g_Server->Receive();
// while (packet) {
// RakNet::BitStream bitStream(packet->data, packet->length, false);
// PacketHandler::HandlePacket(bitStream, packet->systemAddress);
// g_Server->DeallocatePacket(packet);
// packet = g_Server->Receive();
// }
// Handle web requests
Game::web.ReceiveRequests();
// Broadcast dashboard updates periodically
if (elapsedSinceBroadcast >= broadcastInterval) {
BroadcastDashboardUpdate();
lastBroadcast = currentTime;
}
lastTime = currentTime;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
// Cleanup
Database::Destroy("DashboardServer");
delete g_Server;
g_Server = nullptr;
delete Game::logger;
Game::logger = nullptr;
delete Game::config;
Game::config = nullptr;
return EXIT_SUCCESS;
}

View File

@@ -0,0 +1,6 @@
#include "AuthMiddleware.h"
#include "AuthTokenHandler.h"
bool AuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
return AuthTokenHandler::ProcessHTTPContext(context, reply);
}

View File

@@ -0,0 +1,28 @@
#ifndef __AUTHMIDDLEWARE_H__
#define __AUTHMIDDLEWARE_H__
#include <string>
#include <memory>
#include "IHTTPMiddleware.h"
/**
* AuthMiddleware: Extracts and verifies authentication tokens
*
* Token extraction sources (in priority order):
* 1. Query parameter: ?token=eyJhbGc...
* 2. Cookie: dashboardToken=...
* 3. Authorization header: Bearer <token> or Token <token>
*
* Sets HTTPContext.isAuthenticated, HTTPContext.authenticatedUser,
* and HTTPContext.gmLevel if token is valid.
*/
class AuthMiddleware final : public IHTTPMiddleware {
public:
AuthMiddleware() = default;
~AuthMiddleware() override = default;
bool Process(HTTPContext& context, HTTPReply& reply) override;
std::string GetName() const override { return "AuthMiddleware"; }
};
#endif // !__AUTHMIDDLEWARE_H__

View File

@@ -0,0 +1,186 @@
#include "AuthTokenHandler.h"
#include "DashboardAuthService.h"
#include "Game.h"
#include "Logger.h"
#include "HTTPContext.h"
#include "Web.h"
// Helper to extract cookie value from header
static std::string ExtractCookieValue(const std::string& cookieHeader, const std::string& cookieName) {
std::string searchStr = cookieName + "=";
size_t pos = cookieHeader.find(searchStr);
if (pos == std::string::npos) {
return "";
}
size_t valueStart = pos + searchStr.length();
size_t valueEnd = cookieHeader.find(";", valueStart);
if (valueEnd == std::string::npos) {
valueEnd = cookieHeader.length();
}
std::string value = cookieHeader.substr(valueStart, valueEnd - valueStart);
// URL decode the value
std::string decoded;
for (size_t i = 0; i < value.length(); ++i) {
if (value[i] == '%' && i + 2 < value.length()) {
std::string hex = value.substr(i + 1, 2);
char* endptr;
int charCode = static_cast<int>(std::strtol(hex.c_str(), &endptr, 16));
if (endptr - hex.c_str() == 2) {
decoded += static_cast<char>(charCode);
i += 2;
continue;
}
}
decoded += value[i];
}
return decoded;
}
std::string AuthTokenHandler::ExtractTokenFromQueryString(const std::string& queryString) {
if (queryString.empty()) {
return "";
}
// Parse query string to find token parameter
// Expected format: "?token=eyJhbGc..."
std::string tokenPrefix = "token=";
size_t tokenPos = queryString.find(tokenPrefix);
if (tokenPos == std::string::npos) {
return "";
}
// Extract token value (from "token=" to next "&" or end of string)
size_t valueStart = tokenPos + tokenPrefix.length();
size_t valueEnd = queryString.find("&", valueStart);
if (valueEnd == std::string::npos) {
valueEnd = queryString.length();
}
return queryString.substr(valueStart, valueEnd - valueStart);
}
std::string AuthTokenHandler::ExtractTokenFromCookieHeader(const std::string& cookieHeader) {
if (cookieHeader.empty()) {
return "";
}
// Extract dashboardToken cookie value
return ExtractCookieValue(cookieHeader, "dashboardToken");
}
std::string AuthTokenHandler::ExtractTokenFromAuthHeader(const std::string& authHeader) {
if (authHeader.empty()) {
return "";
}
// Check for "Bearer <token>" format
if (authHeader.length() >= 7 && authHeader.substr(0, 7) == "Bearer ") {
return authHeader.substr(7);
}
// Check for "Token <token>" format
if (authHeader.length() >= 6 && authHeader.substr(0, 6) == "Token ") {
return authHeader.substr(6);
}
// If no prefix, assume raw token
return authHeader;
}
std::string AuthTokenHandler::ExtractToken(
const std::string& queryString,
const std::string& cookieHeader,
const std::string& authHeader
) {
// Try in priority order: query string, cookie, auth header
std::string token = ExtractTokenFromQueryString(queryString);
if (!token.empty()) {
return token;
}
token = ExtractTokenFromCookieHeader(cookieHeader);
if (!token.empty()) {
return token;
}
token = ExtractTokenFromAuthHeader(authHeader);
return token;
}
AuthTokenHandler::TokenValidationResult AuthTokenHandler::ValidateToken(const std::string& token) {
TokenValidationResult result;
if (token.empty()) {
result.isValid = false;
result.errorMessage = "No token provided";
return result;
}
// Verify JWT token
std::string username;
uint8_t gmLevel = 0;
if (!DashboardAuthService::VerifyToken(token, username, gmLevel)) {
result.isValid = false;
result.errorMessage = "Invalid or expired token";
LOG_DEBUG("Token validation failed");
return result;
}
result.isValid = true;
result.username = username;
result.gmLevel = gmLevel;
LOG_DEBUG("Token validated successfully for user: %s (GM Level: %d)", username.c_str(), gmLevel);
return result;
}
AuthTokenHandler::TokenValidationResult AuthTokenHandler::ExtractAndValidateToken(
const std::string& queryString,
const std::string& cookieHeader,
const std::string& authHeader
) {
TokenValidationResult result;
// Extract token from any source
std::string token = ExtractToken(queryString, cookieHeader, authHeader);
if (token.empty()) {
result.isValid = false;
result.errorMessage = "No authentication token found";
return result;
}
// Validate the token
return ValidateToken(token);
}
bool AuthTokenHandler::ProcessHTTPContext(HTTPContext& context, HTTPReply& reply) {
// Extract and validate token from all available sources
const std::string& queryString = context.queryString;
const std::string& cookieHeader = context.GetHeader("Cookie");
const std::string& authHeader = context.GetHeader("Authorization");
auto result = ExtractAndValidateToken(queryString, cookieHeader, authHeader);
if (result.isValid) {
context.isAuthenticated = true;
context.authenticatedUser = result.username;
context.gmLevel = result.gmLevel;
LOG_DEBUG("User %s authenticated via API token (GM level %d)", result.username.c_str(), result.gmLevel);
return true;
} else {
LOG_DEBUG("Authentication token validation failed: %s", result.errorMessage.c_str());
return true; // Continue - let routes decide if auth is required
}
}

View File

@@ -0,0 +1,90 @@
#pragma once
#include <string>
#include <cstdint>
/**
* Centralized authentication token handler
* Consolidates token extraction from multiple sources and validation
* Used by both HTTP API routes and WebSocket connections
*/
class AuthTokenHandler {
public:
/**
* Result of token extraction and validation
*/
struct TokenValidationResult {
bool isValid{false};
std::string username{};
uint8_t gmLevel{0};
std::string errorMessage{};
};
/**
* Extract token from query string
* Expected format: "?token=eyJhbGc..." or "?token=xyz&other=abc"
* @param queryString The query string from the request
* @return The token value, or empty string if not found
*/
static std::string ExtractTokenFromQueryString(const std::string& queryString);
/**
* Extract token from Cookie header
* Looks for "dashboardToken=<value>" in the cookie string
* @param cookieHeader The Cookie header value
* @return The token value, or empty string if not found
*/
static std::string ExtractTokenFromCookieHeader(const std::string& cookieHeader);
/**
* Extract token from Authorization header
* Supports "Bearer <token>", "Token <token>", or raw token
* @param authHeader The Authorization header value
* @return The token value, or empty string if not found
*/
static std::string ExtractTokenFromAuthHeader(const std::string& authHeader);
/**
* Extract token from any available source
* Tries in priority order: query string, cookie, auth header
* @param queryString The query string
* @param cookieHeader The Cookie header
* @param authHeader The Authorization header
* @return The first token found, or empty string
*/
static std::string ExtractToken(
const std::string& queryString,
const std::string& cookieHeader,
const std::string& authHeader
);
/**
* Validate a token and extract user information
* Checks JWT signature, expiration, and user permissions
* @param token The JWT token
* @return TokenValidationResult with validity status and user info
*/
static TokenValidationResult ValidateToken(const std::string& token);
/**
* Convenience method: Extract and validate token in one call
* @param queryString Query string from request
* @param cookieHeader Cookie header from request
* @param authHeader Authorization header from request
* @return TokenValidationResult with validity status and user info
*/
static TokenValidationResult ExtractAndValidateToken(
const std::string& queryString,
const std::string& cookieHeader,
const std::string& authHeader
);
/**
* Process authentication for HTTP middleware use
* Extracts and validates token from request, sets HTTPContext properties
* @param context HTTP request context (modified to include auth info)
* @param reply HTTP reply (not modified unless validation fails silently)
* @return true to continue middleware chain, false to stop
*/
static bool ProcessHTTPContext(class HTTPContext& context, class HTTPReply& reply);
};

View File

@@ -0,0 +1,29 @@
set(DASHBOARDAUTH_SOURCES
"JWTUtils.cpp"
"DashboardAuthService.cpp"
"AuthMiddleware.cpp"
"AuthTokenHandler.cpp"
"RequireAuthMiddleware.cpp"
)
add_library(DashboardAuth STATIC ${DASHBOARDAUTH_SOURCES})
target_include_directories(DashboardAuth PRIVATE
"${PROJECT_SOURCE_DIR}/dCommon"
"${PROJECT_SOURCE_DIR}/dCommon/dClient"
"${PROJECT_SOURCE_DIR}/dCommon/dEnums"
"${PROJECT_SOURCE_DIR}/dDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase/CDClientTables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/ITables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/MySQL"
"${PROJECT_SOURCE_DIR}/dNet"
"${PROJECT_SOURCE_DIR}/dWeb"
"${PROJECT_SOURCE_DIR}/dServer"
"${PROJECT_SOURCE_DIR}/thirdparty"
"${PROJECT_SOURCE_DIR}/thirdparty/nlohmann"
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth"
)
target_link_libraries(DashboardAuth PRIVATE ${COMMON_LIBRARIES} dWeb dServer bcrypt OpenSSL::Crypto)

View File

@@ -0,0 +1,144 @@
#include "DashboardAuthService.h"
#include "JWTUtils.h"
#include "Database.h"
#include "Logger.h"
#include "Game.h"
#include "dConfig.h"
#include "GeneralUtils.h"
#include <bcrypt/bcrypt.h>
#include <ctime>
namespace {
constexpr int64_t LOCKOUT_DURATION = 15 * 60; // 15 minutes in seconds
}
DashboardAuthService::LoginResult DashboardAuthService::Login(
const std::string& username,
const std::string& password,
bool rememberMe) {
LoginResult result;
if (username.empty() || password.empty()) {
result.message = "Username and password are required";
return result;
}
if (password.length() > 40) {
result.message = "Password exceeds maximum length (40 characters)";
return result;
}
try {
// Get account info
auto accountInfo = Database::Get()->GetAccountInfo(username);
if (!accountInfo) {
result.message = "Invalid username or password";
LOG_DEBUG("Login attempt for non-existent user: %s", username.c_str());
return result;
}
uint32_t accountId = accountInfo->id;
// Check if account is locked
bool isLockedOut = Database::Get()->IsLockedOut(accountId);
if (isLockedOut) {
// Record failed attempt even without checking password
Database::Get()->RecordFailedAttempt(accountId);
uint8_t failedAttempts = Database::Get()->GetFailedAttempts(accountId);
result.message = "Account is locked due to too many failed attempts";
result.accountLocked = true;
LOG("Login attempt on locked account: %s (failed attempts: %d)", username.c_str(), failedAttempts);
return result;
}
// Check password
if (::bcrypt_checkpw(password.c_str(), accountInfo->bcryptPassword.c_str()) != 0) {
// Record failed attempt
Database::Get()->RecordFailedAttempt(accountId);
uint8_t newFailedAttempts = Database::Get()->GetFailedAttempts(accountId);
// Lock account after 3 failed attempts
if (newFailedAttempts >= 3) {
int64_t lockoutUntil = std::time(nullptr) + LOCKOUT_DURATION;
Database::Get()->SetLockout(accountId, lockoutUntil);
result.message = "Account locked due to too many failed attempts";
result.accountLocked = true;
LOG("Account locked after failed attempts: %s", username.c_str());
} else {
result.message = "Invalid username or password";
LOG_DEBUG("Failed login attempt for user: %s (attempt %d/3)",
username.c_str(), newFailedAttempts);
}
return result;
}
// Check GM level
if (!HasDashboardAccess(static_cast<uint8_t>(accountInfo->maxGmLevel))) {
result.message = "Access denied: insufficient permissions";
LOG("Access denied for non-admin user: %s", username.c_str());
return result;
}
// Successful login
Database::Get()->ClearFailedAttempts(accountId);
result.success = true;
result.gmLevel = static_cast<uint8_t>(accountInfo->maxGmLevel);
result.token = JWTUtils::GenerateToken(username, result.gmLevel, rememberMe);
result.message = "Login successful";
LOG("Successful login: %s (GM Level: %d)", username.c_str(), result.gmLevel);
return result;
} catch (const std::exception& ex) {
result.message = "An error occurred during login";
LOG("Error during login process: %s", ex.what());
return result;
}
}
bool DashboardAuthService::VerifyToken(const std::string& token, std::string& username, uint8_t& gmLevel) {
JWTUtils::JWTPayload payload;
if (!JWTUtils::ValidateToken(token, payload)) {
LOG_DEBUG("Token validation failed: invalid or expired JWT");
return false;
}
username = payload.username;
gmLevel = payload.gmLevel;
// Optionally verify user still exists and has access
try {
auto accountInfo = Database::Get()->GetAccountInfo(username);
if (!accountInfo || !HasDashboardAccess(static_cast<uint8_t>(accountInfo->maxGmLevel))) {
LOG_DEBUG("Token verification failed: user no longer has access");
return false;
}
} catch (const std::exception& ex) {
LOG_DEBUG("Error verifying user during token validation: %s", ex.what());
return false;
}
LOG_DEBUG("Token verified successfully for user: %s (GM Level: %d)", username.c_str(), gmLevel);
return true;
}
bool DashboardAuthService::HasDashboardAccess(uint8_t gmLevel) {
// Get minimum GM level from config (default 0 = any user)
uint8_t minGmLevel = 0;
if (Game::config) {
const std::string& minGmLevelStr = Game::config->GetValue("min_dashboard_gm_level");
if (!minGmLevelStr.empty()) {
const auto parsed = GeneralUtils::TryParse<uint8_t>(minGmLevelStr);
if (parsed) {
minGmLevel = parsed.value();
}
}
}
return gmLevel >= minGmLevel;
}

View File

@@ -0,0 +1,47 @@
#pragma once
#include <string>
#include <cstdint>
/**
* Dashboard authentication service
* Handles user login, password verification, and account lockout
*/
class DashboardAuthService {
public:
/**
* Login result structure
*/
struct LoginResult {
bool success{false};
std::string message{};
std::string token{}; // JWT token if successful
uint8_t gmLevel{0}; // GM level if successful
bool accountLocked{false}; // Account is locked out
};
/**
* Attempt to log in with username and password
* @param username The username
* @param password The plaintext password (max 40 characters)
* @param rememberMe If true, extends token expiration to 30 days
* @return LoginResult with success status and JWT token if successful
*/
static LoginResult Login(const std::string& username, const std::string& password, bool rememberMe = false);
/**
* Verify that a token is valid and get the username
* @param token The JWT token
* @param username Output parameter for the username
* @param gmLevel Output parameter for the GM level
* @return true if token is valid
*/
static bool VerifyToken(const std::string& token, std::string& username, uint8_t& gmLevel);
/**
* Check if user has required GM level for dashboard access
* @param gmLevel The user's GM level
* @return true if user can access dashboard (GM level > 0)
*/
static bool HasDashboardAccess(uint8_t gmLevel);
};

View File

@@ -0,0 +1,186 @@
#include "JWTUtils.h"
#include "GeneralUtils.h"
#include "Logger.h"
#include "json.hpp"
#include <ctime>
#include <cstring>
#include <openssl/hmac.h>
#include <openssl/sha.h>
namespace {
std::string g_Secret = "default-secret-change-me";
// Simple base64 encoding
std::string Base64Encode(const std::string& input) {
static const char* base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string ret;
int i = 0;
unsigned char char_array_3[3];
unsigned char char_array_4[4];
for (size_t n = 0; n < input.length(); n++) {
char_array_3[i++] = input[n];
if (i == 3) {
char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
char_array_4[3] = char_array_3[2] & 0x3f;
for (i = 0; i < 4; i++) ret += base64_chars[char_array_4[i]];
i = 0;
}
}
if (i) {
for (int j = i; j < 3; j++) char_array_3[j] = '\0';
char_array_4[0] = (char_array_3[0] & 0xfc) >> 2;
char_array_4[1] = ((char_array_3[0] & 0x03) << 4) + ((char_array_3[1] & 0xf0) >> 4);
char_array_4[2] = ((char_array_3[1] & 0x0f) << 2) + ((char_array_3[2] & 0xc0) >> 6);
for (int j = 0; j <= i; j++) ret += base64_chars[char_array_4[j]];
while (i++ < 3) ret += '=';
}
return ret;
}
// Simple base64 decoding
std::string Base64Decode(const std::string& encoded_string) {
static const std::string base64_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
int in_len = encoded_string.size();
int i = 0, j = 0, in_ = 0;
unsigned char char_array_4[4], char_array_3[3];
std::string ret;
while (in_len-- && (encoded_string[in_] != '=') &&
(isalnum(encoded_string[in_]) || encoded_string[in_] == '+' || encoded_string[in_] == '/')) {
char_array_4[i++] = encoded_string[in_]; in_++;
if (i == 4) {
for (i = 0; i < 4; i++) char_array_4[i] = base64_chars.find(char_array_4[i]);
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3];
for (i = 0; i < 3; i++) ret += char_array_3[i];
i = 0;
}
}
if (i) {
for (j = i; j < 4; j++) char_array_4[j] = 0;
for (j = 0; j < 4; j++) char_array_4[j] = base64_chars.find(char_array_4[j]);
char_array_3[0] = (char_array_4[0] << 2) + ((char_array_4[1] & 0x30) >> 4);
char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2);
for (j = 0; j < i - 1; j++) ret += char_array_3[j];
}
return ret;
}
// HMAC-SHA256
std::string HmacSha256(const std::string& key, const std::string& message) {
unsigned char* digest = HMAC(EVP_sha256(),
reinterpret_cast<const unsigned char*>(key.c_str()), key.length(),
reinterpret_cast<const unsigned char*>(message.c_str()), message.length(),
nullptr, nullptr);
std::string result(reinterpret_cast<char*>(digest), SHA256_DIGEST_LENGTH);
return result;
}
// Create signature for JWT
std::string CreateSignature(const std::string& header, const std::string& payload, const std::string& secret) {
std::string message = header + "." + payload;
std::string signature = HmacSha256(secret, message);
return Base64Encode(signature);
}
// Verify JWT signature
bool VerifySignature(const std::string& header, const std::string& payload, const std::string& signature, const std::string& secret) {
std::string expected = CreateSignature(header, payload, secret);
return signature == expected;
}
}
namespace JWTUtils {
void SetSecretKey(const std::string& secret) {
if (secret.empty()) {
LOG("Warning: JWT secret key is empty, using default");
return;
}
g_Secret = secret;
}
std::string GenerateToken(const std::string& username, uint8_t gmLevel, bool rememberMe) {
// Header
std::string header = R"({"alg":"HS256","typ":"JWT"})";
std::string encodedHeader = Base64Encode(header);
// Payload
int64_t now = std::time(nullptr);
int64_t expiresAt = now + (rememberMe ? 30 * 24 * 60 * 60 : 24 * 60 * 60); // 30 days or 24 hours
std::string payload = R"({"username":")" + username + R"(","gmLevel":)" + std::to_string(gmLevel) +
R"(,"rememberMe":)" + (rememberMe ? "true" : "false") +
R"(,"iat":)" + std::to_string(now) +
R"(,"exp":)" + std::to_string(expiresAt) + "}";
std::string encodedPayload = Base64Encode(payload);
// Signature
std::string signature = CreateSignature(encodedHeader, encodedPayload, g_Secret);
return encodedHeader + "." + encodedPayload + "." + signature;
}
bool ValidateToken(const std::string& token, JWTPayload& payload) {
// Split token into parts
size_t firstDot = token.find('.');
size_t secondDot = token.find('.', firstDot + 1);
if (firstDot == std::string::npos || secondDot == std::string::npos) {
LOG_DEBUG("Invalid JWT format");
return false;
}
std::string header = token.substr(0, firstDot);
std::string encodedPayload = token.substr(firstDot + 1, secondDot - firstDot - 1);
std::string signature = token.substr(secondDot + 1);
// Verify signature
if (!VerifySignature(header, encodedPayload, signature, g_Secret)) {
LOG_DEBUG("Invalid JWT signature");
return false;
}
// Decode and parse payload
std::string decodedPayload = Base64Decode(encodedPayload);
try {
auto json = nlohmann::json::parse(decodedPayload);
payload.username = json.value("username", "");
payload.gmLevel = json.value("gmLevel", 0);
payload.rememberMe = json.value("rememberMe", false);
payload.issuedAt = json.value("iat", 0);
payload.expiresAt = json.value("exp", 0);
if (payload.username.empty()) {
LOG_DEBUG("JWT missing username");
return false;
}
// Check expiration
if (IsTokenExpired(payload.expiresAt)) {
LOG_DEBUG("JWT token expired");
return false;
}
return true;
} catch (const std::exception& ex) {
LOG_DEBUG("Error parsing JWT payload: %s", ex.what());
return false;
}
}
bool IsTokenExpired(int64_t expiresAt) {
return std::time(nullptr) > expiresAt;
}
}

View File

@@ -0,0 +1,52 @@
#pragma once
#include <string>
#include <ctime>
#include "json_fwd.hpp"
/**
* JWT Token utilities for dashboard authentication
* Provides secure token generation, validation, and parsing
*/
namespace JWTUtils {
/**
* JWT payload structure
*/
struct JWTPayload {
std::string username{};
uint8_t gmLevel{0};
bool rememberMe{false};
int64_t issuedAt{0};
int64_t expiresAt{0};
};
/**
* Generate a new JWT token
* @param username The username to encode in the token
* @param gmLevel The GM level of the user
* @param rememberMe If true, extends token expiration to 30 days; otherwise 24 hours
* @return Signed JWT token string
*/
std::string GenerateToken(const std::string& username, uint8_t gmLevel, bool rememberMe = false);
/**
* Validate and decode a JWT token
* @param token The JWT token to validate
* @param payload Output parameter for the decoded payload
* @return true if token is valid and not expired, false otherwise
*/
bool ValidateToken(const std::string& token, JWTPayload& payload);
/**
* Check if a token is expired
* @param expiresAt Expiration timestamp
* @return true if token is expired
*/
bool IsTokenExpired(int64_t expiresAt);
/**
* Set the JWT secret key (must be called once at startup)
* @param secret The secret key for signing tokens
*/
void SetSecretKey(const std::string& secret);
}

View File

@@ -0,0 +1,35 @@
#include "RequireAuthMiddleware.h"
#include "HTTPContext.h"
#include "Web.h"
#include "Game.h"
#include "Logger.h"
RequireAuthMiddleware::RequireAuthMiddleware(uint8_t minGmLevel) : minGmLevel(minGmLevel) {}
bool RequireAuthMiddleware::Process(HTTPContext& context, HTTPReply& reply) {
// Check if user is authenticated
if (!context.isAuthenticated) {
LOG_DEBUG("Unauthorized access attempt to %s from %s", context.path.c_str(), context.clientIP.c_str());
reply.status = eHTTPStatusCode::FOUND;
reply.message = "";
reply.location = "/login";
reply.contentType = eContentType::TEXT_HTML;
return false; // Stop middleware chain and send reply
}
// Check if user has required GM level
if (context.gmLevel < minGmLevel) {
LOG_DEBUG("Forbidden access attempt by user %s (GM level %d < %d required) to %s from %s",
context.authenticatedUser.c_str(), context.gmLevel, minGmLevel,
context.path.c_str(), context.clientIP.c_str());
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Insufficient permissions\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return false; // Stop middleware chain and send reply
}
// Authentication passed
LOG_DEBUG("User %s authenticated with GM level %d accessing %s",
context.authenticatedUser.c_str(), context.gmLevel, context.path.c_str());
return true; // Continue to next middleware or route handler
}

View File

@@ -0,0 +1,30 @@
#ifndef __REQUIREAUTHMIDDLEWARE_H__
#define __REQUIREAUTHMIDDLEWARE_H__
#include <memory>
#include <cstdint>
#include "IHTTPMiddleware.h"
/**
* RequireAuthMiddleware: Enforces authentication on protected routes
*
* Returns 401 Unauthorized if user is not authenticated
* Returns 403 Forbidden if user's GM level is below minimum required
*/
class RequireAuthMiddleware final : public IHTTPMiddleware {
public:
/**
* @param minGmLevel Minimum GM level required to access this route
* 0 = any authenticated user, higher numbers = GM-only
*/
explicit RequireAuthMiddleware(uint8_t minGmLevel = 0);
~RequireAuthMiddleware() override = default;
bool Process(HTTPContext& context, HTTPReply& reply) override;
std::string GetName() const override { return "RequireAuthMiddleware"; }
private:
uint8_t minGmLevel;
};
#endif // !__REQUIREAUTHMIDDLEWARE_H__

View File

@@ -0,0 +1,443 @@
#include "APIRoutes.h"
#include "ServerState.h"
#include "Web.h"
#include "eHTTPMethod.h"
#include "json.hpp"
#include "Game.h"
#include "Database.h"
#include "Logger.h"
#include "HTTPContext.h"
#include "RequireAuthMiddleware.h"
#include <memory>
void RegisterAPIRoutes() {
// GET /api/status - Get overall server status
Game::web.RegisterHTTPRoute({
.path = "/api/status",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
nlohmann::json response = ServerState::GetServerStateJson();
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
}
});
// GET /api/players - Get list of online players
Game::web.RegisterHTTPRoute({
.path = "/api/players",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
nlohmann::json response = {
{"players", nlohmann::json::array()},
{"count", 0}
};
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
}
});
// GET /api/accounts/count - Get total account count
Game::web.RegisterHTTPRoute({
.path = "/api/accounts/count",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
const uint32_t count = Database::Get()->GetAccountCount();
nlohmann::json response = {{"count", count}};
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/accounts/count: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /api/characters/count - Get total character count
Game::web.RegisterHTTPRoute({
.path = "/api/characters/count",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
const uint32_t count = Database::Get()->GetCharacterCount();
nlohmann::json response = {{"count", count}};
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/characters/count: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/accounts - Get accounts table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/accounts",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
// Extract DataTables parameters
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
// Extract search - it can be a string or an object with a "value" property
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
// Extract order parameters
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
// Get the accounts table data
nlohmann::json response = Database::Get()->GetAccountsTable(start, length, search, orderColumn, orderAsc);
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/accounts: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/accounts: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/characters - Get characters table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/characters",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
std::string tableData = Database::Get()->GetCharactersTable(start, length, search, orderColumn, orderAsc);
nlohmann::json response = nlohmann::json::parse(tableData);
response["draw"] = draw;
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/characters: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/characters: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/play_keys - Get play keys table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/play_keys",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try { // Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
std::string tableData = Database::Get()->GetPlayKeysTable(start, length, search, orderColumn, orderAsc);
nlohmann::json response = nlohmann::json::parse(tableData);
response["draw"] = draw;
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/play_keys: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/play_keys: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/properties - Get properties table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/properties",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
std::string tableData = Database::Get()->GetPropertiesTable(start, length, search, orderColumn, orderAsc);
nlohmann::json response = nlohmann::json::parse(tableData);
response["draw"] = draw;
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/properties: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/properties: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/tables/bug_reports - Get bug reports table data (DataTables.js format)
Game::web.RegisterHTTPRoute({
.path = "/api/tables/bug_reports",
.method = eHTTPMethod::POST,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try { // Only admins (GM > 0) can access table data
if (context.gmLevel == 0) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - Admin access required\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
nlohmann::json requestData = nlohmann::json::parse(context.body);
uint32_t draw = requestData.value("draw", 1);
uint32_t start = requestData.value("start", 0);
uint32_t length = requestData.value("length", 10);
std::string search = "";
if (requestData.contains("search")) {
if (requestData["search"].is_string()) {
search = requestData["search"].get<std::string>();
} else if (requestData["search"].is_object() && requestData["search"].contains("value")) {
search = requestData["search"]["value"].get<std::string>();
}
}
uint32_t orderColumn = 0;
bool orderAsc = true;
if (requestData.contains("order") && requestData["order"].is_array() && requestData["order"].size() > 0) {
orderColumn = requestData["order"][0].value("column", 0);
orderAsc = requestData["order"][0].value("dir", "asc") == "asc";
}
std::string tableData = Database::Get()->GetBugReportsTable(start, length, search, orderColumn, orderAsc);
nlohmann::json response = nlohmann::json::parse(tableData);
response["draw"] = draw;
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/tables/bug_reports: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/tables/bug_reports: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /api/accounts/:id - Get single account by ID
Game::web.RegisterHTTPRoute({
.path = "/api/accounts/:id",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Extract account ID from URL path
const std::string path = context.path;
size_t lastSlash = path.rfind('/');
if (lastSlash == std::string::npos) {
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid account ID\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
std::string idStr = path.substr(lastSlash + 1);
uint32_t accountId = 0;
try {
accountId = std::stoul(idStr);
} catch (...) {
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid account ID\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
// Permission check: GM 0 can only view own account, GM > 0 can view any account
if (context.gmLevel == 0) {
// Regular user - get their own account ID
auto currentUserInfo = Database::Get()->GetAccountInfo(context.authenticatedUser);
if (!currentUserInfo.has_value() || currentUserInfo->id != accountId) {
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "{\"error\":\"Forbidden - You do not have permission to view this account\"}";
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
}
// Get account data
nlohmann::json response = Database::Get()->GetAccountById(accountId);
reply.status = eHTTPStatusCode::OK;
reply.message = response.dump();
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const nlohmann::json::exception& jsonEx) {
LOG("JSON error in /api/accounts/:id: %s", jsonEx.what());
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.message = "{\"error\":\"Invalid JSON\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} catch (std::exception& ex) {
LOG("Error in /api/accounts/:id: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Database error\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
}

View File

@@ -0,0 +1,3 @@
#pragma once
void RegisterAPIRoutes();

View File

@@ -0,0 +1,102 @@
#include "AuthRoutes.h"
#include "DashboardAuthService.h"
#include "json.hpp"
#include "Logger.h"
#include "GeneralUtils.h"
#include "Web.h"
#include "eHTTPMethod.h"
#include "HTTPContext.h"
void RegisterAuthRoutes() {
// POST /api/auth/login
// Request body: { "username": "string", "password": "string", "rememberMe": boolean }
// Response: { "success": boolean, "message": "string", "token": "string", "gmLevel": number }
Game::web.RegisterHTTPRoute({
.path = "/api/auth/login",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
auto json = nlohmann::json::parse(context.body);
std::string username = json.value("username", "");
std::string password = json.value("password", "");
bool rememberMe = json.value("rememberMe", false);
// Validate input
if (username.empty() || password.empty()) {
reply.message = R"({"success":false,"message":"Username and password are required"})";
reply.status = eHTTPStatusCode::BAD_REQUEST;
return;
}
if (password.length() > 40) {
reply.message = R"({"success":false,"message":"Password exceeds maximum length"})";
reply.status = eHTTPStatusCode::BAD_REQUEST;
return;
}
// Attempt login
auto result = DashboardAuthService::Login(username, password, rememberMe);
nlohmann::json response;
response["success"] = result.success;
response["message"] = result.message;
if (result.success) {
response["token"] = result.token;
response["gmLevel"] = result.gmLevel;
}
reply.message = response.dump();
reply.status = result.success ? eHTTPStatusCode::OK : eHTTPStatusCode::UNAUTHORIZED;
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const std::exception& ex) {
LOG("Error processing login request: %s", ex.what());
reply.message = R"({"success":false,"message":"Internal server error"})";
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// POST /api/auth/verify
// Request body: { "token": "string" }
// Response: { "valid": boolean, "username": "string", "gmLevel": number }
Game::web.RegisterHTTPRoute({
.path = "/api/auth/verify",
.method = eHTTPMethod::POST,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
auto json = nlohmann::json::parse(context.body);
std::string token = json.value("token", "");
if (token.empty()) {
reply.message = R"({"valid":false})";
reply.status = eHTTPStatusCode::BAD_REQUEST;
reply.contentType = eContentType::APPLICATION_JSON;
return;
}
std::string username;
uint8_t gmLevel{};
bool valid = DashboardAuthService::VerifyToken(token, username, gmLevel);
nlohmann::json response;
response["valid"] = valid;
if (valid) {
response["username"] = username;
response["gmLevel"] = gmLevel;
}
reply.message = response.dump();
reply.status = eHTTPStatusCode::OK;
reply.contentType = eContentType::APPLICATION_JSON;
} catch (const std::exception& ex) {
LOG("Error processing verify request: %s", ex.what());
reply.message = R"({"valid":false})";
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
}

View File

@@ -0,0 +1,10 @@
#pragma once
#include "Web.h"
/**
* Register authentication routes
* /api/auth/login - POST login endpoint
* /api/auth/verify - POST verify token endpoint
*/
void RegisterAuthRoutes();

View File

@@ -0,0 +1,30 @@
set(DASHBOARDROUTES_SOURCES
"APIRoutes.cpp"
"StaticRoutes.cpp"
"DashboardRoutes.cpp"
"WSRoutes.cpp"
"AuthRoutes.cpp"
)
add_library(DashboardRoutes STATIC ${DASHBOARDROUTES_SOURCES})
target_include_directories(DashboardRoutes PRIVATE
"${PROJECT_SOURCE_DIR}/dCommon"
"${PROJECT_SOURCE_DIR}/dCommon/dClient"
"${PROJECT_SOURCE_DIR}/dCommon/dEnums"
"${PROJECT_SOURCE_DIR}/dDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/CDClientDatabase/CDClientTables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/ITables"
"${PROJECT_SOURCE_DIR}/dDatabase/GameDatabase/MySQL"
"${PROJECT_SOURCE_DIR}/dNet"
"${PROJECT_SOURCE_DIR}/dWeb"
"${PROJECT_SOURCE_DIR}/dServer"
"${PROJECT_SOURCE_DIR}/thirdparty"
"${PROJECT_SOURCE_DIR}/thirdparty/nlohmann"
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes"
)
target_link_libraries(DashboardRoutes PRIVATE ${COMMON_LIBRARIES} dWeb dServer)

View File

@@ -0,0 +1,291 @@
#include "DashboardRoutes.h"
#include "ServerState.h"
#include "Web.h"
#include "HTTPContext.h"
#include "eHTTPMethod.h"
#include "json.hpp"
#include "Game.h"
#include "Database.h"
#include "Logger.h"
#include "inja.hpp"
#include "AuthMiddleware.h"
#include "RequireAuthMiddleware.h"
void RegisterDashboardRoutes() {
// GET / - Main dashboard page (requires authentication)
Game::web.RegisterHTTPRoute({
.path = "/",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Initialize inja environment
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Prepare data for template
nlohmann::json data = context.GetUserDataJson();
// Server status - merge with server state
nlohmann::json serverState = ServerState::GetServerStateJson();
data.merge_patch(serverState);
// Statistics
data["stats"]["onlinePlayers"] = 0; // TODO: Get from server communication
data["stats"]["totalAccounts"] = Database::Get()->GetAccountCount();
data["stats"]["totalCharacters"] = Database::Get()->GetCharacterCount();
// Render template
const std::string html = env.render_file("index.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render template\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /login - Login page (no authentication required)
Game::web.RegisterHTTPRoute({
.path = "/login",
.method = eHTTPMethod::GET,
.middleware = {},
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Initialize inja environment
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Render template with empty user data (not authenticated)
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("login.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering login template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render login page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /accounts/:id - View single account
Game::web.RegisterHTTPRoute({
.path = "/accounts/:id",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(0) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Extract account ID from URL path
const std::string path = context.path;
size_t lastSlash = path.rfind('/');
if (lastSlash == std::string::npos) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "<h1>404 - Account not found</h1>";
reply.contentType = eContentType::TEXT_HTML;
return;
}
std::string idStr = path.substr(lastSlash + 1);
uint32_t accountId = 0;
try {
accountId = std::stoul(idStr);
} catch (...) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "<h1>404 - Invalid account ID</h1>";
reply.contentType = eContentType::TEXT_HTML;
return;
}
// Permission check: GM 0 can only view own account, GM > 0 can view any account
if (context.gmLevel == 0) {
LOG("Regular user '%s' (GM level 0) is trying to access account ID %u", context.authenticatedUser.c_str(), accountId);
// Regular user - get their own account ID
auto currentUserInfo = Database::Get()->GetAccountInfo(context.authenticatedUser);
if (!currentUserInfo.has_value() || currentUserInfo->id != accountId) {
LOG("Permission denied: user '%s' cannot access account ID %u", context.authenticatedUser.c_str(), accountId);
reply.status = eHTTPStatusCode::FORBIDDEN;
reply.message = "<h1>403 - Forbidden</h1><p>You do not have permission to view this account.</p>";
reply.contentType = eContentType::TEXT_HTML;
return;
}
}
// Get account data from API
nlohmann::json account = Database::Get()->GetAccountById(accountId);
// Check if account was found
if (account.contains("error")) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "<h1>404 - Account not found</h1>";
reply.contentType = eContentType::TEXT_HTML;
return;
}
// Initialize inja environment
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Prepare data for template
nlohmann::json data = context.GetUserDataJson();
data["account"] = account;
// Render template
const std::string html = env.render_file("account-view.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering account view template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "<h1>500 - Server Error</h1>";
reply.contentType = eContentType::TEXT_HTML;
}
}
});
// GET /accounts - Accounts management page
Game::web.RegisterHTTPRoute({
.path = "/accounts",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
// Initialize inja environment
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Prepare data for template
nlohmann::json data = context.GetUserDataJson();
// Render template
const std::string html = env.render_file("accounts.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering accounts template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render accounts page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /characters - Characters management page
Game::web.RegisterHTTPRoute({
.path = "/characters",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("characters.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering characters template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render characters page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /play_keys - Play keys management page
Game::web.RegisterHTTPRoute({
.path = "/play_keys",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("play_keys.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering play_keys template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render play_keys page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /properties - Properties management page
Game::web.RegisterHTTPRoute({
.path = "/properties",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("properties.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering properties template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render properties page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
// GET /bug_reports - Bug reports management page
Game::web.RegisterHTTPRoute({
.path = "/bug_reports",
.method = eHTTPMethod::GET,
.middleware = { std::make_shared<RequireAuthMiddleware>(1) },
.handle = [](HTTPReply& reply, const HTTPContext& context) {
try {
inja::Environment env{"dDashboardServer/templates/"};
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
nlohmann::json data = context.GetUserDataJson();
const std::string html = env.render_file("bug_reports.jinja2", data);
reply.status = eHTTPStatusCode::OK;
reply.message = html;
reply.contentType = eContentType::TEXT_HTML;
} catch (const std::exception& ex) {
LOG("Error rendering bug_reports template: %s", ex.what());
reply.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR;
reply.message = "{\"error\":\"Failed to render bug_reports page\"}";
reply.contentType = eContentType::APPLICATION_JSON;
}
}
});
}

View File

@@ -0,0 +1,7 @@
#pragma once
#include "json.hpp"
class HTTPContext;
void RegisterDashboardRoutes();

View File

@@ -0,0 +1,52 @@
#pragma once
#include <chrono>
#include <vector>
#include <string>
#include <cstdint>
#include "json.hpp"
struct ServerStatus {
bool online{false};
uint32_t players{0};
std::string version{};
std::chrono::steady_clock::time_point lastSeen{};
};
struct WorldInstanceInfo {
uint32_t mapID{0};
uint32_t instanceID{0};
uint32_t cloneID{0};
uint32_t players{0};
std::string ip{};
uint32_t port{0};
bool isPrivate{false};
};
namespace ServerState {
extern ServerStatus g_AuthStatus;
extern ServerStatus g_ChatStatus;
extern std::vector<WorldInstanceInfo> g_WorldInstances;
// Helper function to get all server state as JSON
inline nlohmann::json GetServerStateJson() {
nlohmann::json data;
data["auth"]["online"] = g_AuthStatus.online;
data["auth"]["players"] = g_AuthStatus.players;
data["chat"]["online"] = g_ChatStatus.online;
data["chat"]["players"] = g_ChatStatus.players;
data["worlds"] = nlohmann::json::array();
for (const auto& world : g_WorldInstances) {
data["worlds"].push_back({
{"mapID", world.mapID},
{"instanceID", world.instanceID},
{"cloneID", world.cloneID},
{"players", world.players},
{"isPrivate", world.isPrivate}
});
}
return data;
}
}

View File

@@ -0,0 +1,68 @@
#include "StaticRoutes.h"
#include "Web.h"
#include "HTTPContext.h"
#include "eHTTPMethod.h"
#include "Game.h"
#include "Logger.h"
#include <fstream>
#include <sstream>
namespace {
std::string ReadFileToString(const std::string& filePath) {
std::ifstream file(filePath);
if (!file.is_open()) {
LOG("Failed to open file: %s", filePath.c_str());
return "";
}
std::stringstream buffer{};
buffer << file.rdbuf();
return buffer.str();
}
eContentType GetContentType(const std::string& filePath) {
if (filePath.ends_with(".css")) {
return eContentType::TEXT_CSS;
} else if (filePath.ends_with(".js")) {
return eContentType::TEXT_JAVASCRIPT;
} else if (filePath.ends_with(".html")) {
return eContentType::TEXT_HTML;
} else if (filePath.ends_with(".png")) {
return eContentType::IMAGE_PNG;
} else if (filePath.ends_with(".jpg") || filePath.ends_with(".jpeg")) {
return eContentType::IMAGE_JPEG;
} else if (filePath.ends_with(".json")) {
return eContentType::APPLICATION_JSON;
}
return eContentType::TEXT_PLAIN;
}
void ServeStaticFile(const std::string& urlPath, const std::string& filePath) {
Game::web.RegisterHTTPRoute({
.path = urlPath,
.method = eHTTPMethod::GET,
.middleware = {},
.handle = [filePath](HTTPReply& reply, const HTTPContext& context) {
const std::string content = ReadFileToString(filePath);
if (content.empty()) {
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "{\"error\":\"File not found\"}";
reply.contentType = eContentType::APPLICATION_JSON;
} else {
reply.status = eHTTPStatusCode::OK;
reply.message = content;
reply.contentType = GetContentType(filePath);
}
}
});
}
}
void RegisterStaticRoutes() {
// Serve CSS files
ServeStaticFile("/css/dashboard.css", "dDashboardServer/static/css/dashboard.css");
ServeStaticFile("/css/login.css", "dDashboardServer/static/css/login.css");
// Serve JavaScript files
ServeStaticFile("/js/dashboard.js", "dDashboardServer/static/js/dashboard.js");
ServeStaticFile("/js/login.js", "dDashboardServer/static/js/login.js");
}

View File

@@ -0,0 +1,3 @@
#pragma once
void RegisterStaticRoutes();

View File

@@ -0,0 +1,232 @@
#include "WSRoutes.h"
#include "ServerState.h"
#include "Web.h"
#include "json.hpp"
#include "Game.h"
#include "Database.h"
#include "Logger.h"
void RegisterWSRoutes() {
// Register WebSocket subscriptions for real-time updates
Game::web.RegisterWSSubscription("dashboard_update");
Game::web.RegisterWSSubscription("server_status");
Game::web.RegisterWSSubscription("player_joined");
Game::web.RegisterWSSubscription("player_left");
// Account subscriptions
Game::web.RegisterWSSubscription("account_update");
Game::web.RegisterWSSubscription("accounts_table_update");
Game::web.RegisterWSSubscription("account_list_changed");
// Character subscriptions
Game::web.RegisterWSSubscription("character_update");
Game::web.RegisterWSSubscription("characters_table_update");
Game::web.RegisterWSSubscription("character_list_changed");
// Property subscriptions
Game::web.RegisterWSSubscription("property_update");
Game::web.RegisterWSSubscription("properties_table_update");
Game::web.RegisterWSSubscription("property_list_changed");
// Play Key subscriptions
Game::web.RegisterWSSubscription("play_key_update");
Game::web.RegisterWSSubscription("play_keys_table_update");
Game::web.RegisterWSSubscription("play_key_list_changed");
// Bug Report subscriptions
Game::web.RegisterWSSubscription("bug_report_update");
Game::web.RegisterWSSubscription("bug_reports_table_update");
}
void BroadcastDashboardUpdate() {
// Get server state data (auth, chat, worlds) - mutex is acquired internally
nlohmann::json data = ServerState::GetServerStateJson();
// Add statistics
try {
data["stats"]["onlinePlayers"] = 0; // TODO: Get from server communication
data["stats"]["totalAccounts"] = Database::Get()->GetAccountCount();
data["stats"]["totalCharacters"] = Database::Get()->GetCharacterCount();
} catch (const std::exception& ex) {
LOG_DEBUG("Error getting stats: %s", ex.what());
}
// Broadcast to all connected WebSocket clients subscribed to "dashboard_update"
Game::web.SendWSMessage("dashboard_update", data);
}
void BroadcastAccountUpdate(uint32_t accountId) {
try {
// Get updated account data
nlohmann::json accountData = Database::Get()->GetAccountById(accountId);
// Only broadcast if account was found
if (!accountData.contains("error")) {
accountData["event"] = "account_update";
accountData["accountId"] = accountId;
Game::web.SendWSMessage("account_update", accountData);
}
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting account update: %s", ex.what());
}
}
void BroadcastAccountsTableUpdate() {
try {
// Broadcast that the accounts table has been modified
// Clients should reload the current page of data
nlohmann::json message = {
{"event", "accounts_table_update"},
{"message", "Accounts table has been updated"}
};
Game::web.SendWSMessage("accounts_table_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting accounts table update: %s", ex.what());
}
}
void BroadcastAccountListChanged() {
try {
// Broadcast that accounts list changed (new account created or deleted)
nlohmann::json message = {
{"event", "account_list_changed"},
{"totalAccounts", Database::Get()->GetAccountCount()}
};
Game::web.SendWSMessage("account_list_changed", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting account list change: %s", ex.what());
}
}
void BroadcastCharacterUpdate(uint32_t characterId) {
try {
// Get updated character data and broadcast
nlohmann::json message = {
{"event", "character_update"},
{"characterId", characterId}
};
Game::web.SendWSMessage("character_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting character update: %s", ex.what());
}
}
void BroadcastCharactersTableUpdate() {
try {
nlohmann::json message = {
{"event", "characters_table_update"},
{"message", "Characters table has been updated"}
};
Game::web.SendWSMessage("characters_table_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting characters table update: %s", ex.what());
}
}
void BroadcastCharacterListChanged() {
try {
nlohmann::json message = {
{"event", "character_list_changed"},
{"totalCharacters", Database::Get()->GetCharacterCount()}
};
Game::web.SendWSMessage("character_list_changed", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting character list change: %s", ex.what());
}
}
void BroadcastPropertyUpdate(uint32_t propertyId) {
try {
nlohmann::json message = {
{"event", "property_update"},
{"propertyId", propertyId}
};
Game::web.SendWSMessage("property_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting property update: %s", ex.what());
}
}
void BroadcastPropertiesTableUpdate() {
try {
nlohmann::json message = {
{"event", "properties_table_update"},
{"message", "Properties table has been updated"}
};
Game::web.SendWSMessage("properties_table_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting properties table update: %s", ex.what());
}
}
void BroadcastPropertyListChanged() {
try {
nlohmann::json message = {
{"event", "property_list_changed"},
{"totalProperties", 0} // TODO: Get from database
};
Game::web.SendWSMessage("property_list_changed", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting property list change: %s", ex.what());
}
}
void BroadcastPlayKeyUpdate(uint32_t playKeyId) {
try {
nlohmann::json message = {
{"event", "play_key_update"},
{"playKeyId", playKeyId}
};
Game::web.SendWSMessage("play_key_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting play key update: %s", ex.what());
}
}
void BroadcastPlayKeysTableUpdate() {
try {
nlohmann::json message = {
{"event", "play_keys_table_update"},
{"message", "Play Keys table has been updated"}
};
Game::web.SendWSMessage("play_keys_table_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting play keys table update: %s", ex.what());
}
}
void BroadcastPlayKeyListChanged() {
try {
nlohmann::json message = {
{"event", "play_key_list_changed"},
{"totalPlayKeys", 0} // TODO: Get from database
};
Game::web.SendWSMessage("play_key_list_changed", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting play key list change: %s", ex.what());
}
}
void BroadcastBugReportUpdate(uint32_t bugReportId) {
try {
nlohmann::json message = {
{"event", "bug_report_update"},
{"bugReportId", bugReportId}
};
Game::web.SendWSMessage("bug_report_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting bug report update: %s", ex.what());
}
}
void BroadcastBugReportsTableUpdate() {
try {
nlohmann::json message = {
{"event", "bug_reports_table_update"},
{"message", "Bug Reports table has been updated"}
};
Game::web.SendWSMessage("bug_reports_table_update", message);
} catch (const std::exception& ex) {
LOG_DEBUG("Error broadcasting bug reports table update: %s", ex.what());
}
}

View File

@@ -0,0 +1,31 @@
#pragma once
#include "json.hpp"
#include <cstdint>
void RegisterWSRoutes();
void BroadcastDashboardUpdate();
// Account broadcasts
void BroadcastAccountUpdate(uint32_t accountId);
void BroadcastAccountsTableUpdate();
void BroadcastAccountListChanged();
// Character broadcasts
void BroadcastCharacterUpdate(uint32_t characterId);
void BroadcastCharactersTableUpdate();
void BroadcastCharacterListChanged();
// Property broadcasts
void BroadcastPropertyUpdate(uint32_t propertyId);
void BroadcastPropertiesTableUpdate();
void BroadcastPropertyListChanged();
// Play Key broadcasts
void BroadcastPlayKeyUpdate(uint32_t playKeyId);
void BroadcastPlayKeysTableUpdate();
void BroadcastPlayKeyListChanged();
// Bug Report broadcasts
void BroadcastBugReportUpdate(uint32_t bugReportId);
void BroadcastBugReportsTableUpdate();

View File

@@ -0,0 +1,285 @@
/* DarkflameServer Dashboard - Minimal Branding */
:root {
--primary-color: #667eea;
--secondary-color: #764ba2;
--dark-bg: #0f0f1e;
--card-bg: rgba(26, 26, 46, 0.8);
}
body {
background: linear-gradient(135deg, var(--dark-bg) 0%, #1a1a2e 100%);
display: grid;
grid-template-columns: 280px 1fr;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
}
/* Grid Layout */
.navbar.flex-column {
grid-row: 1 / -1;
grid-column: 1;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
box-shadow: 2px 0 15px rgba(0, 0, 0, 0.3);
overflow-y: auto;
}
main {
grid-column: 2;
grid-row: 2;
display: flex;
flex-direction: column;
}
.navbar:not(.flex-column) {
grid-column: 1 / -1;
grid-row: 1;
background: rgba(26, 26, 46, 0.95);
border-bottom: 1px solid rgba(102, 126, 234, 0.2);
}
footer {
grid-column: 1 / -1;
grid-row: 3;
background: rgba(26, 26, 46, 0.95);
border-top: 1px solid rgba(102, 126, 234, 0.2);
}
/* Sidebar Navigation */
.navbar.flex-column .nav-link {
border-left: 3px solid transparent;
transition: all 0.3s ease;
}
.navbar.flex-column .nav-link:hover {
background-color: rgba(102, 126, 234, 0.15);
border-left-color: var(--primary-color);
}
.navbar.flex-column .nav-link.active {
background-color: rgba(102, 126, 234, 0.25);
border-left-color: var(--primary-color);
color: var(--primary-color);
}
/* Cards */
.card {
background: var(--card-bg);
border: 1px solid rgba(102, 126, 234, 0.2);
transition: all 0.3s ease;
}
.card:hover {
border-color: rgba(102, 126, 234, 0.4);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.15);
}
.card-header {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
border-bottom: 1px solid rgba(102, 126, 234, 0.3);
}
/* Tables */
.table-dark {
border-color: rgba(102, 126, 234, 0.2);
}
.table-dark thead th {
background: rgba(102, 126, 234, 0.15);
border-bottom: 2px solid rgba(102, 126, 234, 0.3);
}
.table-dark tbody tr:hover {
background-color: rgba(102, 126, 234, 0.1);
}
/* DataTables */
.dataTables_wrapper .dataTables_filter input,
.dataTables_wrapper .dataTables_paginate .paginate_button {
background-color: rgba(42, 42, 58, 0.8);
border: 1px solid rgba(102, 126, 234, 0.3);
}
.dataTables_wrapper .dataTables_paginate .paginate_button.current {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
border: none;
}
/* Buttons */
.btn-primary {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
border: none;
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.logout-btn {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
}
/* Custom Badge Styles */
.badge-banned {
background-color: #dc3545;
}
.badge-locked {
background-color: #fd7e14;
}
.badge-gm {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
}
.badge-active {
background-color: #28a745;
}
.badge-inactive {
background-color: #6c757d;
}
.badge-approved {
background-color: #28a745;
}
.badge-pending {
background-color: #ffc107;
color: #000;
}
.badge-primary {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
}
/* Status Indicators */
.status.online::before,
.status.offline::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.status.online::before {
background: #4caf50;
}
.status.offline::before {
background: #f44336;
}
/* Detail Grid */
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.detail-item {
border-left: 3px solid var(--primary-color);
padding-left: 12px;
}
.detail-label {
font-size: 0.875rem;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.detail-value {
font-size: 1rem;
color: #fff;
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 20px;
}
.back-link {
color: var(--primary-color);
text-decoration: none;
font-weight: 600;
transition: all 0.3s;
display: inline-block;
margin-bottom: 20px;
}
.back-link:hover {
color: var(--secondary-color);
}
/* Container Wrappers - Use Bootstrap spacing */
.accounts-container,
.characters-container,
.bug-reports-container,
.properties-container,
.play-keys-container,
.account-view-container {
width: 100%;
}
/* Responsive */
@media (max-width: 991.98px) {
body {
grid-template-columns: 1fr;
}
.navbar.flex-column {
grid-column: 1 / -1;
grid-row: 1;
flex-direction: row;
flex-wrap: wrap;
overflow-y: visible;
}
.navbar.flex-column .nav-link {
border-left: none;
border-bottom: 2px solid transparent;
}
.navbar.flex-column .nav-link:hover,
.navbar.flex-column .nav-link.active {
border-bottom-color: var(--primary-color);
}
main {
grid-column: 1;
grid-row: 2;
}
footer {
grid-column: 1;
grid-row: 3;
}
.detail-grid {
grid-template-columns: 1fr;
}
.table-dark {
font-size: 12px;
min-width: 100%;
}
.table-dark th,
.table-dark td {
white-space: nowrap;
padding: 8px 6px;
}
}
@media (max-width: 575.98px) {
.navbar.flex-column .nav-link {
font-size: 11px;
}
}

View File

@@ -0,0 +1,296 @@
/* Modern Login Page Styling */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body.d-flex.bg-dark.text-white {
background: linear-gradient(135deg, #0f0f1e 0%, #1a1a2e 50%, #16213e 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
min-height: 100vh;
}
/* Hide elements not needed on login */
body .navbar,
body > footer {
display: none !important;
}
body .container-fluid {
padding: 0 !important;
}
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.login-wrapper {
display: grid;
grid-template-columns: 1fr 1fr;
width: 100%;
max-width: 1100px;
min-height: 600px;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
/* Left side - Branding */
.login-left {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 60px 40px;
display: flex;
flex-direction: column;
justify-content: space-between;
color: white;
}
.login-branding {
text-align: center;
}
.login-branding .logo {
font-size: 64px;
margin-bottom: 20px;
}
.login-branding h1 {
font-size: 32px;
font-weight: 700;
margin-bottom: 8px;
letter-spacing: -0.5px;
}
.login-branding p {
font-size: 14px;
opacity: 0.9;
}
.login-info {
text-align: center;
}
.login-info h3 {
font-size: 24px;
font-weight: 600;
margin-bottom: 12px;
}
.login-info p {
font-size: 14px;
opacity: 0.85;
line-height: 1.6;
}
/* Right side - Form */
.login-right {
padding: 60px 40px;
display: flex;
align-items: center;
justify-content: center;
}
.login-form-wrapper {
width: 100%;
}
.login-form-wrapper h2 {
font-size: 28px;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 30px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
position: relative;
display: flex;
flex-direction: column;
}
.form-group label {
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input {
padding: 12px 16px;
font-size: 14px;
border: 2px solid #e0e0e0;
border-radius: 8px;
transition: all 0.3s ease;
font-family: inherit;
color: #1a1a2e;
}
.form-group input::placeholder {
color: #999;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-focus {
height: 2px;
background: linear-gradient(90deg, transparent, #667eea, transparent);
border-radius: 2px;
margin-top: -2px;
opacity: 0;
transition: opacity 0.3s ease;
}
.form-group input:focus ~ .form-focus {
opacity: 1;
}
.form-checkbox {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0 20px 0;
}
.form-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #667eea;
}
.form-checkbox label {
font-size: 13px;
color: #666;
cursor: pointer;
margin: 0;
text-transform: none;
letter-spacing: normal;
}
.btn-login {
padding: 12px 24px;
font-size: 15px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 10px;
}
.btn-login:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.btn-login:active {
transform: translateY(0);
}
.btn-login:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.alert-box {
padding: 12px 16px;
margin-bottom: 20px;
border-radius: 8px;
font-size: 13px;
display: none;
}
.alert-box.alert-danger {
background-color: #fee;
color: #c33;
border: 1px solid #fcc;
}
.alert-box.alert-success {
background-color: #efe;
color: #3c3;
border: 1px solid #cfc;
}
.login-footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
}
.login-footer p {
font-size: 12px;
color: #999;
}
/* Responsive Design */
@media (max-width: 768px) {
.login-wrapper {
grid-template-columns: 1fr;
min-height: auto;
}
.login-left {
padding: 40px 30px;
justify-content: center;
min-height: 250px;
}
.login-right {
padding: 40px 30px;
}
.login-branding .logo {
font-size: 48px;
}
.login-branding h1 {
font-size: 24px;
}
.login-form-wrapper h2 {
font-size: 24px;
}
.form-group input {
font-size: 16px;
}
}

View File

@@ -0,0 +1,254 @@
let ws = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const reconnectDelay = 3000;
// Helper function to get cookie value
function getCookie(name) {
const nameEQ = name + '=';
const cookies = document.cookie.split(';');
for (let cookie of cookies) {
cookie = cookie.trim();
if (cookie.indexOf(nameEQ) === 0) {
return decodeURIComponent(cookie.substring(nameEQ.length));
}
}
return null;
}
// Helper function to delete cookie
function deleteCookie(name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict`;
}
// Check authentication on page load
function checkAuthentication() {
// Check localStorage first (most secure)
let token = localStorage.getItem('dashboardToken');
// Fallback to cookie if localStorage empty
if (!token) {
token = getCookie('dashboardToken');
}
if (!token) {
// Redirect to login if no token
window.location.href = '/login';
return false;
}
// Verify token is valid (asynchronous)
fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token })
})
.then(res => {
if (!res.ok) {
console.error('Verify endpoint returned:', res.status);
throw new Error(`HTTP ${res.status}`);
}
return res.json();
})
.then(data => {
console.log('Token verification response:', data);
if (!data.valid) {
// Token is invalid/expired, delete cookies and redirect to login
console.log('Token verification failed, redirecting to login');
deleteCookie('dashboardToken');
deleteCookie('gmLevel');
localStorage.removeItem('dashboardToken');
window.location.href = '/login';
} else {
// Update UI with username
console.log('Token verified, user:', data.username);
const usernameElement = document.querySelector('.username');
if (usernameElement) {
usernameElement.textContent = data.username || 'User';
} else {
console.warn('Username element not found in DOM');
}
// Now that verification is complete, connect to WebSocket
setTimeout(() => {
console.log('Starting WebSocket connection');
connectWebSocket();
}, 100);
}
})
.catch(err => {
console.error('Token verification error:', err);
// Network error - log but don't redirect immediately
// This prevents redirect loops on network issues
});
return true;
}
// Get token from localStorage or cookie
function getAuthToken() {
let token = localStorage.getItem('dashboardToken');
if (!token) {
token = getCookie('dashboardToken');
}
console.log('getAuthToken called, token available:', !!token);
return token;
}
// Logout function
function logout() {
deleteCookie('dashboardToken');
deleteCookie('gmLevel');
localStorage.removeItem('dashboardToken');
window.location.href = '/login';
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getAuthToken();
if (!token) {
console.error('No token available for WebSocket connection');
window.location.href = '/login';
return;
}
console.log(`WebSocket connection attempt ${reconnectAttempts + 1}/${maxReconnectAttempts}`);
// Connect to WebSocket without token in URL (token is in cookies)
const wsUrl = `${protocol}//${window.location.host}/ws`;
console.log(`Connecting to WebSocket: ${wsUrl}`);
try {
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('WebSocket connected');
reconnectAttempts = 0;
// Subscribe to dashboard updates
ws.send(JSON.stringify({
event: 'subscribe',
subscription: 'dashboard_update'
}));
// Mark connection as ready for other handlers
wsConnectionReady = true;
// Initialize real-time table manager
if (typeof realtimeManager !== 'undefined') {
realtimeManager.Initialize();
}
document.getElementById('connection-status')?.remove();
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Handle subscription confirmation
if (data.subscribed) {
console.log('Subscribed to:', data.subscribed);
return;
}
// Handle dashboard updates
if (data.event === 'dashboard_update') {
updateDashboard(data);
}
// Route to generic realtime handler for all table updates
if (typeof realtimeManager !== 'undefined') {
realtimeManager.HandleMessage(data);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket disconnected');
ws = null;
// Show connection status
showConnectionStatus('Disconnected - Attempting to reconnect...');
// Attempt to reconnect with exponential backoff
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
const backoffDelay = reconnectDelay * Math.pow(2, reconnectAttempts - 1);
console.log(`Reconnecting in ${backoffDelay}ms (attempt ${reconnectAttempts}/${maxReconnectAttempts})`);
setTimeout(connectWebSocket, backoffDelay);
} else {
console.error('Max reconnection attempts reached');
showConnectionStatus('Connection lost - Reload page to reconnect');
}
};
} catch (error) {
console.error('Failed to create WebSocket:', error);
showConnectionStatus('Failed to connect - Reload page to retry');
}
}
function showConnectionStatus(message) {
let statusEl = document.getElementById('connection-status');
if (!statusEl) {
statusEl = document.createElement('div');
statusEl.id = 'connection-status';
statusEl.style.cssText = 'position: fixed; top: 10px; right: 10px; background: #f44336; color: white; padding: 10px 20px; border-radius: 4px; z-index: 1000;';
document.body.appendChild(statusEl);
}
statusEl.textContent = message;
}
function updateDashboard(data) {
// Update server status
if (data.auth) {
document.getElementById('auth-status').textContent = data.auth.online ? 'Online' : 'Offline';
document.getElementById('auth-status').className = 'status ' + (data.auth.online ? 'online' : 'offline');
}
if (data.chat) {
document.getElementById('chat-status').textContent = data.chat.online ? 'Online' : 'Offline';
document.getElementById('chat-status').className = 'status ' + (data.chat.online ? 'online' : 'offline');
}
// Update world instances
if (data.worlds) {
document.getElementById('world-count').textContent = data.worlds.length;
const worldList = document.getElementById('world-list');
if (data.worlds.length === 0) {
worldList.innerHTML = '<div class="loading">No active world instances</div>';
} else {
worldList.innerHTML = data.worlds.map(world => `
<div class="world-item">
<h3>Zone ${world.mapID} - Instance ${world.instanceID}</h3>
<div class="world-detail">Clone ID: ${world.cloneID}</div>
<div class="world-detail">Players: ${world.players}</div>
<div class="world-detail">Type: ${world.isPrivate ? 'Private' : 'Public'}</div>
</div>
`).join('');
}
}
// Update statistics
if (data.stats) {
if (data.stats.onlinePlayers !== undefined) {
document.getElementById('online-players').textContent = data.stats.onlinePlayers;
}
if (data.stats.totalAccounts !== undefined) {
document.getElementById('total-accounts').textContent = data.stats.totalAccounts;
}
if (data.stats.totalCharacters !== undefined) {
document.getElementById('total-characters').textContent = data.stats.totalCharacters;
}
}
}
// Connect on page load
connectWebSocket();

View File

@@ -0,0 +1,99 @@
// Check if user is already logged in
function checkExistingToken() {
const token = localStorage.getItem('dashboardToken');
if (token) {
verifyTokenAndRedirect(token);
}
}
function verifyTokenAndRedirect(token) {
fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token })
})
.then(res => res.json())
.then(data => {
if (data.valid) {
window.location.href = '/';
}
})
.catch(err => console.error('Token verification failed:', err));
}
function showAlert(message, type) {
const alert = document.getElementById('alert');
alert.textContent = message;
alert.className = 'alert';
if (type === 'error') {
alert.classList.add('alert-danger');
} else if (type === 'success') {
alert.classList.add('alert-success');
}
alert.style.display = 'block';
}
// Wait for DOM to be ready
document.addEventListener('DOMContentLoaded', () => {
const loginForm = document.getElementById('loginForm');
if (!loginForm) {
console.error('Login form not found');
return;
}
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('rememberMe').checked;
// Validate input
if (!username || !password) {
showAlert('Username and password are required', 'error');
return;
}
if (password.length > 40) {
showAlert('Password exceeds maximum length (40 characters)', 'error');
return;
}
// Show loading state
document.getElementById('loading').style.display = 'inline-block';
document.getElementById('loginBtn').disabled = true;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, rememberMe })
});
const data = await response.json();
if (data.success) {
// Store token in localStorage (also set as cookie for API calls)
localStorage.setItem('dashboardToken', data.token);
document.cookie = `dashboardToken=${data.token}; path=/; SameSite=Strict`;
showAlert('Login successful! Redirecting...', 'success');
// Redirect after a short delay (no token in URL)
setTimeout(() => {
window.location.href = '/';
}, 1000);
} else {
showAlert(data.message || 'Login failed', 'error');
document.getElementById('loading').style.display = 'none';
document.getElementById('loginBtn').disabled = false;
}
} catch (error) {
showAlert('Network error: ' + error.message, 'error');
document.getElementById('loading').style.display = 'none';
document.getElementById('loginBtn').disabled = false;
}
});
// Check existing token on page load
checkExistingToken();
});

View File

@@ -0,0 +1,339 @@
/**
* Generic Real-time WebSocket Updates for Dashboard Tables
* Provides reactive, non-blocking updates for all admin tables
*/
const realtimeManager = {
tables: {},
currentEntityId: null,
wsReady: false,
/**
* Initialize real-time updates for a DataTable
* @param {string} entityType - Type of entity (account, character, property, etc.)
* @param {jQuery} tableElement - jQuery DataTable element
*/
RegisterTable(entityType, tableElement) {
if (!tableElement || !tableElement.DataTable) {
console.warn(`Invalid table element for entity type: ${entityType}`);
return;
}
this.tables[entityType] = {
element: tableElement,
table: tableElement.DataTable(),
lastUpdate: Date.now()
};
console.log(`Registered real-time table for entity type: ${entityType}`);
},
/**
* Initialize WebSocket listeners for all entity types
*/
Initialize() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.warn('WebSocket not ready for realtime initialization');
return;
}
this.wsReady = true;
console.log('Initialized real-time WebSocket listeners');
this.SubscribeToAll();
},
/**
* Subscribe to all registered entity types
*/
SubscribeToAll() {
const subscriptions = [
'account_update',
'accounts_table_update',
'account_list_changed',
'character_update',
'characters_table_update',
'character_list_changed',
'property_update',
'properties_table_update',
'property_list_changed',
'play_key_update',
'play_keys_table_update',
'play_key_list_changed',
'bug_report_update',
'bug_reports_table_update'
];
for (const subscription of subscriptions) {
if (!ws || ws.readyState !== WebSocket.OPEN) break;
ws.send(JSON.stringify({
event: 'subscribe',
subscription: subscription
}));
}
console.log(`Subscribed to ${subscriptions.length} real-time events`);
},
/**
* Handle incoming WebSocket messages
*/
HandleMessage(data) {
if (!data.event) return;
// Route to appropriate handler based on event type
if (data.event.includes('account')) {
this.HandleAccountEvent(data);
} else if (data.event.includes('character')) {
this.HandleCharacterEvent(data);
} else if (data.event.includes('property')) {
this.HandlePropertyEvent(data);
} else if (data.event.includes('play_key')) {
this.HandlePlayKeyEvent(data);
} else if (data.event.includes('bug_report')) {
this.HandleBugReportEvent(data);
}
},
/**
* Handle account-related events
*/
HandleAccountEvent(data) {
if (data.event === 'account_update') {
this.UpdateRow('account', data.accountId || data.id, data);
// Also update detail panel if viewing this account
this.UpdateDetailPanel('account', data);
} else if (data.event === 'accounts_table_update') {
this.ReloadTable('account');
} else if (data.event === 'account_list_changed') {
this.UpdateListCount('account', data.totalAccounts);
this.ReloadTable('account', true);
}
},
/**
* Handle character-related events
*/
HandleCharacterEvent(data) {
if (data.event === 'character_update') {
this.UpdateRow('character', data.characterId || data.id, data);
} else if (data.event === 'characters_table_update') {
this.ReloadTable('character');
} else if (data.event === 'character_list_changed') {
this.UpdateListCount('character', data.totalCharacters);
this.ReloadTable('character', true);
}
},
/**
* Handle property-related events
*/
HandlePropertyEvent(data) {
if (data.event === 'property_update') {
this.UpdateRow('property', data.propertyId || data.id, data);
} else if (data.event === 'properties_table_update') {
this.ReloadTable('property');
} else if (data.event === 'property_list_changed') {
this.UpdateListCount('property', data.totalProperties);
this.ReloadTable('property', true);
}
},
/**
* Handle play key-related events
*/
HandlePlayKeyEvent(data) {
if (data.event === 'play_key_update') {
this.UpdateRow('play_key', data.playKeyId || data.id, data);
} else if (data.event === 'play_keys_table_update') {
this.ReloadTable('play_key');
} else if (data.event === 'play_key_list_changed') {
this.UpdateListCount('play_key', data.totalPlayKeys);
this.ReloadTable('play_key', true);
}
},
/**
* Handle bug report-related events
*/
HandleBugReportEvent(data) {
if (data.event === 'bug_report_update') {
this.UpdateRow('bug_report', data.bugReportId || data.id, data);
} else if (data.event === 'bug_reports_table_update') {
this.ReloadTable('bug_report');
}
},
/**
* Update a single row in a DataTable
*/
UpdateRow(entityType, entityId, data) {
if (!this.tables[entityType]) {
console.debug(`No table registered for entity type: ${entityType}`);
return;
}
try {
const table = this.tables[entityType].table;
const rows = table.rows().nodes();
// Find and invalidate the matching row
let found = false;
for (let row of rows) {
const rowData = table.row(row).data();
if (rowData && (rowData.id === entityId || rowData.accountId === entityId ||
rowData.characterId === entityId || rowData.propertyId === entityId ||
rowData.playKeyId === entityId)) {
table.row(row).invalidate().draw(false);
found = true;
break;
}
}
if (found) {
this.ShowToast('Updated', `${entityType} data has been refreshed`, 'info');
}
} catch (e) {
console.debug(`Error updating row for ${entityType}:`, e);
}
},
/**
* Reload a table without page change
*/
ReloadTable(entityType, resetPage = false) {
if (!this.tables[entityType]) {
console.debug(`No table registered for entity type: ${entityType}`);
return;
}
try {
const table = this.tables[entityType].table;
if (resetPage) {
table.page(0).ajax.reload();
} else {
table.ajax.reload(null, false);
}
this.ShowToast('Updated', `${entityType} list has been refreshed`, 'info');
} catch (e) {
console.error(`Error reloading table for ${entityType}:`, e);
}
},
/**
* Update list count display
*/
UpdateListCount(entityType, count) {
try {
const selector = `.${entityType}-count`;
const countEl = document.querySelector(selector);
if (countEl) {
countEl.textContent = count;
}
} catch (e) {
console.debug(`Error updating count for ${entityType}:`, e);
}
},
/**
* Set current entity being viewed for detail updates
*/
SetCurrentEntity(entityType, entityId) {
this.currentEntity = { type: entityType, id: entityId };
console.log(`Viewing ${entityType} ID: ${entityId}`);
},
/**
* Clear current entity
*/
ClearCurrentEntity() {
this.currentEntity = null;
},
/**
* Update detail panel for current entity
*/
UpdateDetailPanel(entityType, data) {
if (!this.currentEntity || this.currentEntity.type !== entityType ||
this.currentEntity.id !== (data.id || data.accountId)) {
return;
}
try {
// Update all elements with data-field attributes
for (const [key, value] of Object.entries(data)) {
const selectors = [
`.detail-${key}`,
`[data-field="${key}"]`,
`.${entityType}-${key}`
];
for (const selector of selectors) {
const elements = document.querySelectorAll(selector);
for (const el of elements) {
this.UpdateElement(el, key, value);
}
}
}
this.ShowToast('Updated', `${entityType} data refreshed`, 'info');
} catch (e) {
console.error(`Error updating detail panel:`, e);
}
},
/**
* Update an individual element with new value
*/
UpdateElement(element, fieldName, value) {
if (!element) return;
try {
// Handle different field types
if (fieldName.includes('banned') || fieldName.includes('banned') || fieldName.includes('locked')) {
element.textContent = value ? 'Yes' : 'No';
element.className = `badge ${value ? 'bg-danger' : 'bg-success'}`;
} else if (fieldName.includes('count') || fieldName.includes('level')) {
element.textContent = value || '-';
} else if (fieldName.includes('date') || fieldName.includes('created') || fieldName.includes('updated')) {
if (value) {
element.textContent = new Date(value * 1000).toLocaleString();
}
} else {
element.textContent = value || '-';
}
} catch (e) {
console.debug(`Error updating element for field ${fieldName}:`, e);
}
},
/**
* Show toast notification
*/
ShowToast(title, message, type = 'info') {
try {
const toastEl = document.createElement('div');
toastEl.className = `alert alert-${type === 'error' ? 'danger' : type} alert-dismissible fade show`;
toastEl.setAttribute('role', 'alert');
toastEl.style.cssText = 'position: fixed; bottom: 20px; right: 20px; z-index: 9999; min-width: 300px;';
toastEl.innerHTML = `
<strong>${title}</strong><br>${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
document.body.appendChild(toastEl);
setTimeout(() => {
toastEl.remove();
}, 4000);
} catch (e) {
console.log(`[${type}] ${title}: ${message}`);
}
}
};
// Export to global scope
window.realtimeManager = realtimeManager;

View File

@@ -0,0 +1,154 @@
{% extends "base.jinja2" %}
{% block title %}Account - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="account-view-container">
<div class="container-fluid">
<a href="/accounts" class="back-link">← Back to Accounts</a>
<div class="card">
<div class="card-header">
<h2>Account #{{ account.id }} - <span class="account-name">{{ account.name }}</span></h2>
<p class="text-muted">View account details and manage settings</p>
</div>
<div class="card-body">
<div class="detail-grid">
<div class="detail-item">
<div class="detail-label">Account ID</div>
<div class="detail-value">{{ account.id }}</div>
</div>
<div class="detail-item">
<div class="detail-label">Username</div>
<div class="detail-value"><span class="account-name">{{ account.name }}</span></div>
</div>
<div class="detail-item">
<div class="detail-label">Created</div>
<div class="detail-value">{{ account.created_at }}</div>
</div>
<div class="detail-item">
<div class="detail-label">GM Level</div>
<div class="detail-value">
{% if account.gm_level > 0 %}
<span class="account-gm-level badge badge-gm">GM {{ account.gm_level }}</span>
{% else %}
<span class="account-gm-level badge badge-inactive">User</span>
{% endif %}
</div>
</div>
<div class="detail-item">
<div class="detail-label">Ban Status</div>
<div class="detail-value">
{% if account.banned %}
<span class="account-banned badge badge-banned">BANNED</span>
{% else %}
<span class="account-banned badge badge-active">Active</span>
{% endif %}
</div>
</div>
<div class="detail-item">
<div class="detail-label">Lock Status</div>
<div class="detail-value">
{% if account.locked %}
<span class="account-locked badge badge-locked">LOCKED</span>
{% else %}
<span class="account-locked badge badge-active">Unlocked</span>
{% endif %}
</div>
</div>
<div class="detail-item">
<div class="detail-label">Mute Expires</div>
<div class="detail-value">
<span class="account-mute-expire">
{% if account.mute_expire > 0 %}
Muted until {{ account.mute_expire }}
{% else %}
Not muted
{% endif %}
</span>
</div>
</div>
</div>
<div class="button-group">
<button class="btn btn-primary" onclick="EditAccount()">Edit Account</button>
{% if not account.banned %}
<button class="btn btn-danger" onclick="BanAccount()">Ban Account</button>
{% else %}
<button class="btn btn-secondary" onclick="UnbanAccount()">Unban Account</button>
{% endif %}
{% if not account.locked %}
<button class="btn btn-danger" onclick="LockAccount()">Lock Account</button>
{% else %}
<button class="btn btn-secondary" onclick="UnlockAccount()">Unlock Account</button>
{% endif %}
</div>
</div>
</div>
<!-- TODO: Add modals for edit, ban, lock operations -->
<!-- TODO: Add character list for this account -->
<!-- TODO: Add login history -->
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/realtime.js"></script>
<script>
// Set current account for real-time detail panel updates
document.addEventListener('DOMContentLoaded', () => {
realtimeManager.SetCurrentEntity('account', {{ account.id }});
// Clean up when leaving the page
window.addEventListener('beforeunload', () => {
realtimeManager.ClearCurrentEntity();
});
});
function EditAccount() {
alert("Edit functionality coming soon");
// TODO: Open edit modal
}
function BanAccount() {
if (confirm("Are you sure you want to ban this account?")) {
alert("Ban functionality coming soon");
// TODO: Call ban API endpoint
// After successful ban, call: BroadcastAccountUpdate({{ account.id }})
}
}
function UnbanAccount() {
if (confirm("Are you sure you want to unban this account?")) {
alert("Unban functionality coming soon");
// TODO: Call unban API endpoint
// After successful unban, call: BroadcastAccountUpdate({{ account.id }})
}
}
function LockAccount() {
if (confirm("Are you sure you want to lock this account?")) {
alert("Lock functionality coming soon");
// TODO: Call lock API endpoint
// After successful lock, call: BroadcastAccountUpdate({{ account.id }})
}
}
function UnlockAccount() {
if (confirm("Are you sure you want to unlock this account?")) {
alert("Unlock functionality coming soon");
// TODO: Call unlock API endpoint
// After successful unlock, call: BroadcastAccountUpdate({{ account.id }})
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,153 @@
{% extends "base.jinja2" %}
{% block title %}Accounts - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="accounts-container">
<div class="container-fluid">
<div class="card">
<div class="card-header">
<h2>Accounts</h2>
<p class="text-muted">View and manage user accounts - <span class="account-count">0</span> total</p>
</div>
<div class="card-body">
<div class="table-responsive">
<table id="accountsTable" class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Banned</th>
<th>Locked</th>
<th>GM Level</th>
<th>Mute Expires</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/realtime.js"></script>
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
const table = $('#accountsTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/accounts',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
},
error: function(xhr, error, thrown) {
console.error('Error loading accounts:', error);
if (xhr.status === 401) {
window.location.href = '/login';
}
}
},
columns: [
{ data: 'id' },
{ data: 'name' },
{
data: 'banned',
render: function(data) {
return data ? '<span class="badge badge-banned">Banned</span>' : '<span class="badge bg-success">Active</span>';
}
},
{
data: 'locked',
render: function(data) {
return data ? '<span class="badge badge-locked">Locked</span>' : '<span class="badge bg-secondary">Unlocked</span>';
}
},
{
data: 'gm_level',
render: function(data) {
if (data === 0) return '-';
return '<span class="badge badge-gm">Level ' + data + '</span>';
}
},
{
data: 'mute_expire',
render: function(data) {
if (data === 0) return 'Not Muted';
const now = Math.floor(Date.now() / 1000);
const isMuted = data > now;
const date = new Date(data * 1000);
const dateStr = date.toLocaleString();
if (isMuted) {
return '<span class="badge bg-danger">Muted until ' + dateStr + '</span>';
} else {
return '<span class="badge bg-success">Expired ' + dateStr + '</span>';
}
}
},
{
data: 'created_at',
render: function(data) {
return data ? new Date(data).toLocaleString() : '-';
}
},
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewAccount(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-warning" onclick="editAccount(' + data + ')" title="Edit">✏️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'asc']],
stateSave: false
});
// Register table for real-time updates
realtimeManager.RegisterTable('account', table);
// Update account count when DataTable loads
table.on('draw.dt', function() {
const info = table.page.info();
const countEl = document.querySelector('.account-count');
if (countEl && info) {
countEl.textContent = info.recordsTotal;
}
});
// Initialize real-time listener when WebSocket is ready
const waitForWS = setInterval(() => {
if (typeof realtimeManager !== 'undefined' && wsConnectionReady) {
clearInterval(waitForWS);
}
}, 100);
});
function viewAccount(id) {
window.location.href = '/accounts/' + id;
}
function editAccount(id) {
alert('Edit account: ' + id);
// TODO: Implement account edit modal
}
</script>
{% endblock %}

View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}DarkflameServer{% endblock %}</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous">
<link href="https://cdn.datatables.net/v/bs5/jq-3.7.0/dt-2.3.7/b-3.2.6/fh-4.0.6/sc-2.4.3/datatables.min.css" rel="stylesheet" integrity="sha384-XMNDGLb5fN9IqhIrVXOAtGKcz4KCr+JSHXGZ1TDXQPDukbEAfmLPjHdCXhgK93fv" crossorigin="anonymous">
<link rel="stylesheet" href="/css/dashboard.css">
{% block css %}{% endblock %}
</head>
<body class="d-flex bg-dark text-white">
{% if username and username != "" %}
{% include "header.jinja2" %}
{% endif %}
<div class="container-fluid py-3">
{% block content_before %}{% endblock %}
{% block content %}{% endblock %}
{% block content_after %}{% endblock %}
</div>
<footer class="mt-5 pt-5 border-top border-secondary text-center pb-3">
{% block footer %}
<p class="text-muted small">DarkflameServer Dashboard &copy; 2024</p>
{% endblock %}
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.0/js/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
<script src="https://cdn.datatables.net/v/bs5/jq-3.7.0/dt-2.3.7/b-3.2.6/fh-4.0.6/sc-2.4.3/datatables.min.js" integrity="sha384-BPUXtS4tH3onFfu5m+dPbFfpLOXQwSWGwrsNWxOAAwqqJx6tJHhFkGF6uitrmEui" crossorigin="anonymous"></script>
<script>
// Global logout function (available on all pages)
function deleteCookie(name) {
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; SameSite=Strict`;
}
function logout() {
deleteCookie('dashboardToken');
deleteCookie('gmLevel');
localStorage.removeItem('dashboardToken');
window.location.href = '/login';
}
// Setup logout button
document.addEventListener('DOMContentLoaded', () => {
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.addEventListener('click', (e) => {
e.preventDefault();
logout();
});
}
});
</script>
<!-- Load dashboard.js only on dashboard pages (not on login) -->
<script>
const isLoginPage = document.querySelector('.login-container') !== null;
if (!isLoginPage) {
const script = document.createElement('script');
script.src = '/js/dashboard.js';
document.head.appendChild(script);
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,119 @@
{% extends "base.jinja2" %}
{% block title %}Bug Reports - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="bug-reports-container">
<div class="container-fluid">
<div class="card">
<div class="card-header">
<h2>Bug Reports <span class="bug_report-count">0</span></h2>
<p class="text-muted">View and manage bug reports from players</p>
</div>
<div class="card-body">
<table id="bugReportsTable" class="table table-dark table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Player</th>
<th>Client Version</th>
<th>Submitted</th>
<th>Report Preview</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/realtime.js"></script>
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
const table = $('#bugReportsTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/bug_reports',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
}
},
columns: [
{ data: 'id' },
{ data: 'other_player_id' },
{ data: 'client_version' },
{
data: 'submitted',
render: function(data) {
return data ? new Date(data).toLocaleString() : '-';
}
},
{
data: 'body',
render: function(data) {
return '<span class="report-preview" title="' + (data || '') + '">' + (data || '-') + '</span>';
}
},
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewReport(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-danger" onclick="deleteReport(' + data + ')" title="Delete">🗑️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'desc']],
stateSave: false
});
// Register table for real-time updates
realtimeManager.RegisterTable('bug_report', table);
// Update bug report count on table draw
table.on('draw.dt', function() {
const info = table.page.info();
const countEl = document.querySelector('.bug_report-count');
if (countEl && info) {
countEl.textContent = info.recordsTotal;
}
});
// Initialize real-time listener when WebSocket is ready
const waitForWS = setInterval(() => {
if (typeof realtimeManager !== 'undefined' && wsConnectionReady) {
clearInterval(waitForWS);
}
}, 100);
});
function viewReport(id) {
alert('View report: ' + id);
// TODO: Implement report view modal
}
function deleteReport(id) {
if (confirm('Are you sure you want to delete this report?')) {
alert('Delete report: ' + id);
// TODO: Implement report deletion
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,112 @@
{% extends "base.jinja2" %}
{% block title %}Characters - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="characters-container">
<div class="container-fluid">
<div class="card">
<div class="card-header">
<h2>Characters</h2>
<p class="text-muted">View and manage player characters - <span class="character-count">0</span> total</p>
</div>
<div class="card-body">
<table id="charactersTable" class="table table-dark table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Account</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/realtime.js"></script>
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
const table = $('#charactersTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/characters',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
}
},
columns: [
{ data: 'id' },
{ data: 'name' },
{ data: 'account_name' },
{
data: 'last_login',
render: function(data) {
if (data === 0) return 'Never';
const date = new Date(data * 1000);
return date.toLocaleString();
}
},
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewCharacter(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-warning" onclick="editCharacter(' + data + ')" title="Edit">✏️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'asc']],
stateSave: false
});
// Register table for real-time updates
realtimeManager.RegisterTable('character', table);
// Update character count when DataTable loads
table.on('draw.dt', function() {
const info = table.page.info();
const countEl = document.querySelector('.character-count');
if (countEl && info) {
countEl.textContent = info.recordsTotal;
}
});
// Initialize WebSocket realtime updates
const waitForWS = setInterval(() => {
if (typeof realtimeManager !== 'undefined' && wsConnectionReady) {
clearInterval(waitForWS);
}
}, 100);
});
function viewCharacter(id) {
alert('View character: ' + id);
// TODO: Implement character view modal
}
function editCharacter(id) {
alert('Edit character: ' + id);
// TODO: Implement character edit modal
}
</script>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{# Navigation #}
<nav class="navbar navbar-dark bg-dark flex-column">
<div class="p-3">
<a class="navbar-brand fw-bold" href="/">🎮 DarkflameServer</a>
</div>
<ul class="navbar-nav flex-column w-100 flex-grow-1 p-3">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/accounts">Accounts</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/characters">Characters</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/play_keys">Play Keys</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/properties">Properties</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/bug_reports">Bug Reports</a>
</li>
<li class="nav-item mt-auto">
<a class="nav-link" href="#" id="logoutBtn">Logout</a>
</li>
</ul>
</nav>

View File

@@ -0,0 +1,35 @@
{% extends "base.jinja2" %}
{% block title %}Dashboard - DarkflameServer{% endblock %}
{% block content %}
<div class="container-fluid py-5 px-4">
<div class="dashboard-header mb-4">
<div>
<h1>Welcome to DarkflameServer Dashboard</h1>
<p class="text-muted">Real-time server monitoring and administration</p>
</div>
</div>
<div class="row g-3">
{% include "server_status.jinja2" %}
{% include "statistics.jinja2" %}
</div>
{% include "world_instances.jinja2" %}
</div>
{% endblock %}
{% block scripts %}
<script>
// Initialize dashboard when dashboard.js is loaded
document.addEventListener('DOMContentLoaded', () => {
// Wait a bit for dashboard.js to load
setTimeout(() => {
if (typeof checkAuthentication === 'function') {
checkAuthentication();
}
}, 100);
});
</script>
{% endblock %}

View File

@@ -0,0 +1,65 @@
{% extends "base.jinja2" %}
{% block title %}Dashboard Login - DarkflameServer{% endblock %}
{% block css %}
<link rel="stylesheet" href="/css/login.css">
{% endblock %}
{% block content %}
<div class="login-container">
<div class="login-wrapper">
<div class="login-left">
<div class="login-branding">
<div class="logo">⚙️</div>
<h1>DarkflameServer</h1>
<p>Administration Dashboard</p>
</div>
<div class="login-info">
<h3>Welcome Back</h3>
<p>Manage your DarkflameServer infrastructure with ease. Secure access to server administration tools.</p>
</div>
</div>
<div class="login-right">
<div class="login-form-wrapper">
<h2>Sign In</h2>
<div id="alert" class="alert-box" role="alert" style="display: none;"></div>
<form id="loginForm" class="login-form">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus placeholder="Enter your username">
<div class="form-focus"></div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required maxlength="40" placeholder="Enter your password">
<div class="form-focus"></div>
</div>
<div class="form-checkbox">
<input type="checkbox" id="rememberMe" name="rememberMe">
<label for="rememberMe">Remember me for 30 days</label>
</div>
<button type="submit" class="btn-login" id="loginBtn">
<span id="loading" class="spinner" style="display: none;"></span>
<span class="btn-text">Sign In</span>
</button>
</form>
<div class="login-footer">
<p>Contact your administrator if you don't have access</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/login.js"></script>
{% endblock %}

View File

@@ -0,0 +1,117 @@
{% extends "base.jinja2" %}
{% block title %}Play Keys - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="play-keys-container">
<div class="container-fluid">
<div class="card">
<div class="card-header">
<h2>Play Keys <span class="play_key-count">0</span></h2>
<p class="text-muted">View and manage play keys</p>
</div>
<div class="card-body">
<table id="playKeysTable" class="table table-dark table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Key String</th>
<th>Uses Remaining</th>
<th>Created</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/realtime.js"></script>
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
const table = $('#playKeysTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/play_keys',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
}
},
columns: [
{ data: 'id' },
{ data: 'key_string' },
{ data: 'key_uses' },
{
data: 'created_at',
render: function(data) {
return data ? new Date(data).toLocaleString() : '-';
}
},
{
data: 'active',
render: function(data) {
return data ? '<span class="badge badge-active">Active</span>' : '<span class="badge badge-inactive">Inactive</span>';
}
},
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewKey(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-warning" onclick="editKey(' + data + ')" title="Edit">✏️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'asc']],
stateSave: false
});
// Register table for real-time updates
realtimeManager.RegisterTable('play_key', table);
// Update play key count on table draw
table.on('draw.dt', function() {
const info = table.page.info();
const countEl = document.querySelector('.play_key-count');
if (countEl && info) {
countEl.textContent = info.recordsTotal;
}
});
// Initialize real-time listener when WebSocket is ready
const waitForWS = setInterval(() => {
if (typeof realtimeManager !== 'undefined' && wsConnectionReady) {
clearInterval(waitForWS);
}
}, 100);
});
function viewKey(id) {
alert('View key: ' + id);
// TODO: Implement key view modal
}
function editKey(id) {
alert('Edit key: ' + id);
// TODO: Implement key edit modal
}
</script>
{% endblock %}

View File

@@ -0,0 +1,114 @@
{% extends "base.jinja2" %}
{% block title %}Properties - DarkflameServer{% endblock %}
{% block css %}{% endblock %}
{% block content %}
<div class="properties-container">
<div class="container-fluid">
<div class="card">
<div class="card-header">
<h2>Properties <span class="property-count">0</span></h2>
<p class="text-muted">View and manage player properties</p>
</div>
<div class="card-body">
<table id="propertiesTable" class="table table-dark table-striped table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Owner ID</th>
<th>Moderation Status</th>
<th>Reputation</th>
<th>Zone</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- Data populated by DataTables -->
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/realtime.js"></script>
<script>
$(document).ready(function() {
// Initialize DataTable with server-side processing
const table = $('#propertiesTable').DataTable({
processing: true,
serverSide: true,
pageLength: 25,
lengthMenu: [10, 25, 50, 100],
ajax: {
url: '/api/tables/properties',
type: 'POST',
contentType: 'application/json',
data: function(d) {
return JSON.stringify(d);
}
},
columns: [
{ data: 'id' },
{ data: 'name' },
{ data: 'owner_id' },
{
data: 'mod_approved',
render: function(data) {
return data ? '<span class="badge badge-approved">Approved</span>' : '<span class="badge badge-pending">Pending</span>';
}
},
{ data: 'reputation' },
{ data: 'zone_id' },
{
data: 'id',
render: function(data) {
return '<div class="account-actions">' +
'<button class="btn btn-sm btn-info" onclick="viewProperty(' + data + ')" title="View">👁️</button>' +
'<button class="btn btn-sm btn-warning" onclick="editProperty(' + data + ')" title="Edit">✏️</button>' +
'</div>';
},
orderable: false,
searchable: false
}
],
order: [[0, 'asc']],
stateSave: false
});
// Register table for real-time updates
realtimeManager.RegisterTable('property', table);
// Update property count on table draw
table.on('draw.dt', function() {
const info = table.page.info();
const countEl = document.querySelector('.property-count');
if (countEl && info) {
countEl.textContent = info.recordsTotal;
}
});
// Initialize real-time listener when WebSocket is ready
const waitForWS = setInterval(() => {
if (typeof realtimeManager !== 'undefined' && wsConnectionReady) {
clearInterval(waitForWS);
}
}, 100);
});
function viewProperty(id) {
alert('View property: ' + id);
// TODO: Implement property view modal
}
function editProperty(id) {
alert('Edit property: ' + id);
// TODO: Implement property edit modal
}
</script>
{% endblock %}

View File

@@ -0,0 +1,29 @@
<div class="col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">Server Status</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Auth Server</span>
{% if auth.online %}
<span class="badge badge-active" id="auth-status">Online</span>
{% else %}
<span class="badge badge-banned" id="auth-status">Offline</span>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Chat Server</span>
{% if chat.online %}
<span class="badge badge-active" id="chat-status">Online</span>
{% else %}
<span class="badge badge-banned" id="chat-status">Offline</span>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center">
<span>Active Worlds</span>
<span class="badge badge-primary" id="world-count">{{ length(worlds) }}</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,21 @@
<div class="col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-header">
<h5 class="mb-0">Statistics</h5>
</div>
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Online Players</span>
<span class="badge badge-primary" id="online-players">{{ stats.onlinePlayers }}</span>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<span>Total Accounts</span>
<span class="badge badge-primary" id="total-accounts">{{ stats.totalAccounts }}</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span>Total Characters</span>
<span class="badge badge-primary" id="total-characters">{{ stats.totalCharacters }}</span>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,37 @@
<div class="card mt-4">
<div class="card-header">
<h2>Active World Instances</h2>
</div>
<div class="card-body">
<div id="world-list">
{% if length(worlds) == 0 %}
<p class="text-muted text-center mb-0">No active world instances</p>
{% else %}
<div class="table-responsive">
<table class="table table-dark table-hover mb-0">
<thead>
<tr>
<th>Zone</th>
<th>Instance</th>
<th>Clone</th>
<th>Players</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for world in worlds %}
<tr>
<td>{{ world.mapID }}</td>
<td>{{ world.instanceID }}</td>
<td>{{ world.cloneID }}</td>
<td><span class="badge badge-primary">{{ world.players }}</span></td>
<td>{% if world.isPrivate %}<span class="badge badge-warning">Private</span>{% else %}<span class="badge badge-success">Public</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>

View File

@@ -1,6 +1,5 @@
#include "CDActivitiesTable.h"
void CDActivitiesTable::LoadValuesFromDatabase() {
// First, get the size of the table
uint32_t size = 0;
@@ -56,3 +55,13 @@ std::vector<CDActivities> CDActivitiesTable::Query(std::function<bool(CDActiviti
return data;
}
std::optional<const CDActivities> CDActivitiesTable::GetActivity(const uint32_t activityID) {
auto& entries = GetEntries();
for (const auto& entry : entries) {
if (entry.ActivityID == activityID) {
return entry;
}
}
return std::nullopt;
}

View File

@@ -2,6 +2,7 @@
// Custom Classes
#include "CDTable.h"
#include <optional>
struct CDActivities {
uint32_t ActivityID;
@@ -31,4 +32,5 @@ public:
// Queries the table with a custom "where" clause
std::vector<CDActivities> Query(std::function<bool(CDActivities)> predicate);
std::optional<const CDActivities> GetActivity(const uint32_t activityID);
};

View File

@@ -154,8 +154,8 @@ std::map<LOT, uint32_t> CDItemComponentTable::ParseCraftingCurrencies(const CDIt
// Checking for 2 here, not sure what to do when there's more stuff than expected
if (amountSplit.size() == 2) {
currencies.insert({
std::stoull(amountSplit[0]),
std::stoi(amountSplit[1])
GeneralUtils::TryParse<LOT>(amountSplit[0], LOT_NULL),
GeneralUtils::TryParse<uint32_t>(amountSplit[1], 0)
});
}
}

View File

@@ -93,13 +93,14 @@ std::vector<CDMissions> CDMissionsTable::Query(std::function<bool(CDMissions)> p
}
const CDMissions* CDMissionsTable::GetPtrByMissionID(uint32_t missionID) const {
const CDMissions* toReturn = &Default;
for (const auto& entry : GetEntries()) {
if (entry.id == missionID) {
return const_cast<CDMissions*>(&entry);
toReturn = &entry;
}
}
return &Default;
return toReturn;
}
const CDMissions& CDMissionsTable::GetByMissionID(uint32_t missionID, bool& found) const {

View File

@@ -66,6 +66,7 @@ public:
// Queries the table with a custom "where" clause
std::vector<CDMissions> Query(std::function<bool(CDMissions)> predicate);
// Cannot be null.
const CDMissions* GetPtrByMissionID(uint32_t missionID) const;
const CDMissions& GetByMissionID(uint32_t missionID, bool& found) const;

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