diff --git a/.gitea/CONTRIBUTING.md b/.gitea/CONTRIBUTING.md index b7b4679..a378b46 100644 --- a/.gitea/CONTRIBUTING.md +++ b/.gitea/CONTRIBUTING.md @@ -1,12 +1,17 @@ # Contributing Setup & Guidelines ## Setting up the Development Environment + ### 1. Clone the repository from GitHub + ### 2. Install HEMTT + The latest version of HEMTT can be installed by running: + ```cmd winget install hemtt ``` ## Coding Guidelines + This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines). diff --git a/.gitea/ISSUE_TEMPLATE/bug-report.md b/.gitea/ISSUE_TEMPLATE/bug-report.md index d4c384f..2e818b5 100644 --- a/.gitea/ISSUE_TEMPLATE/bug-report.md +++ b/.gitea/ISSUE_TEMPLATE/bug-report.md @@ -1,25 +1,31 @@ --- name: Bug report about: Create a bug report to help us improve -title: '' +title: "" labels: kind/bug --- ## Describe the bug + A clear and concise description of what the bug is. ## To reproduce + Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error ## Expected behavior + A clear and concise description of what you expected to happen. ## Attachments + If applicable, add screenshots or RPT logs to help explain your problem. ## Additional context + Add any other context about the problem here. diff --git a/.gitea/ISSUE_TEMPLATE/feature-request.md b/.gitea/ISSUE_TEMPLATE/feature-request.md index 709ee6c..7bd655d 100644 --- a/.gitea/ISSUE_TEMPLATE/feature-request.md +++ b/.gitea/ISSUE_TEMPLATE/feature-request.md @@ -1,15 +1,18 @@ --- name: Feature Request about: Suggest a feature to be added -title: '' +title: "" labels: kind/feature-request --- ## Describe the feature that you would like + A clear and concise description of the feature you'd want. ## Possible alternatives + Possible alternatives to your suggestion. ## Additional context + Add any other context about the feature here. diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md index 6f72c35..1721684 100644 --- a/.gitea/PULL_REQUEST_TEMPLATE.md +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -1,12 +1,16 @@ **When merged this pull request will:** + - Describe what this pull request will do - Each change in a separate line ### Important + - [ ] If the contribution affects [the documentation](../docs), please include your changes in this pull request. - [ ] [Development Guidelines](https://github.com/IDSolutions/MOD_REPO/blob/main/.github/CONTRIBUTING.md) are read, understood and applied. - [ ] Title of this PR uses our standard template `Component - Add|Fix|Improve|Change|Make|Remove {changes}`. + ### Known Issues + - [ ] Issue diff --git a/.gitignore b/.gitignore index ced595b..65107c9 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,12 @@ target/ *.swo *~ +# Misc +node_modules/ + # OS .DS_Store Thumbs.db + +# Arma +arma/ui/map-viewer/ diff --git a/Architecture_Diagram.md b/Architecture_Diagram.md index 686faf6..85521aa 100644 --- a/Architecture_Diagram.md +++ b/Architecture_Diagram.md @@ -20,7 +20,7 @@ graph TD end subgraph Server [ArmA 3 SERVER #40;Hot Cache#41;] - ActorRegistry["GVAR(ActorRegistry)
In-Memory HashMap
UID -> {loadout, position, stats...}"] + Registry["GVAR(Registry)
In-Memory HashMap
UID -> {loadout, position, stats...}"] SessionMgmt[Session Management
- Token Generation
- UID Resolution
- Player State] end @@ -105,15 +105,18 @@ sequenceDiagram ## 🚀 **Performance Characteristics** ### **Access Times** + - **Hot Cache (Server)**: `< 1ms` (HashMap lookup) - **Cold Storage (Redis)**: `1-5ms` (Network + Redis) - **Client Cache**: `< 0.1ms` (Local object access) ### **Cache Hit Ratios** + - **Hot Cache**: `~95%` (Active players) - **Cold Storage**: `~5%` (New connections, cache misses) ### **Memory Usage** + - **Server Registry**: `~1KB per active player` - **Client Cache**: `~500B per player object` - **Redis**: `~2KB per player (persistent)` @@ -125,7 +128,7 @@ flowchart TD subgraph SessionMgmt [SERVER-SIDE #40;Session MGT#41;] Conn[Player Connection] --> Token[Session Token Generation
#40;Generated on server#41;] Token --> UID[UID Resolution
#40;Steam UID mapping#41;] - UID --> State[Player State Tracking
#40;Tracked in ActorRegistry#41;] + UID --> State[Player State Tracking
#40;Tracked in Registry#41;] State --> Access[Data Access Authorized
#40;Authorized via session#41;] end ``` diff --git a/LICENSE.md b/LICENSE.md index 659cbdc..0cb1a9b 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -6,10 +6,10 @@ PLEASE, NOTE THAT THIS SUMMARY HAS NO LEGAL EFFECT AND IS ONLY OF AN INFORMATORY With this licence you are free to adapt (i.e. modify, rework or update) and share (i.e. copy, distribute or transmit) the material under the following conditions: -* **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material). -* **Noncommercial** - You may not use this material for any commercial purposes. -* **Arma Only** - You may not convert or adapt this material to be used in other games than Arma. -* **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license. +- **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material). +- **Noncommercial** - You may not use this material for any commercial purposes. +- **Arma Only** - You may not convert or adapt this material to be used in other games than Arma. +- **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license. --- @@ -97,7 +97,7 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your 2. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 2. upon express reinstatement by the Licensor. - For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. + For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. 3. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. 4. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. @@ -116,4 +116,4 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your ### Bohemia Interactive Notices 1. Bohemia Interactive a.s. is not a party to this License, and makes no warranty whatsoever in connection with the Licensed Material. Bohemia Interactive a.s. will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, Bohemia Interactive a.s. may elect to apply the Public License to material it publishes and in those instances it becomes the "Licensor". -2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s. \ No newline at end of file +2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s. diff --git a/README.md b/README.md index 7d1392a..c97d5c2 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ graph TD ``` **Communication Flow**: + - **Clients** → Use events (`CBA_Events`) to communicate with server - **Server** → Calls Rust extension via `callExtension` - **Extension** → Manages Redis connection pool and data operations @@ -87,12 +88,14 @@ forge/ 1. Clone the repository from Gitea 2. Install HEMTT -The latest version of HEMTT can be installed by running: + The latest version of HEMTT can be installed by running: + ```cmd winget install hemtt ``` ### Coding Guidelines + This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines). ### Building the Extension @@ -143,14 +146,18 @@ private _update = createHashMapFromArray [["bank", 1500]]; ## Core Modules ### Models + Defines strict data structures with built-in validation: + - `Actor`: Player data (stats, inventory, position) - `Org`: Organization/clan data (members, roles, metadata) [Documentation](lib/models/README.md) ### Repositories + Manages data persistence with Redis: + - Hash-based storage for structured data - Set-based storage for collections - Generic over Redis client implementations @@ -158,7 +165,9 @@ Manages data persistence with Redis: [Documentation](lib/repositories/README.md) ### Services + Implements business logic and orchestration: + - Get-or-create patterns - Data validation and transformation - Complex workflows @@ -166,7 +175,9 @@ Implements business logic and orchestration: [Documentation](lib/services/README.md) ### Extension + Arma 3 interface layer: + - Command routing and parsing - Session management - Error handling and logging @@ -174,7 +185,9 @@ Arma 3 interface layer: [Documentation](arma/server/extension/README.md) ### Client Mod + Client-side SQF addon that provides: + - **UI Components**: Player interfaces for inventory, organizations, banking - **Event Handlers**: CBA event listeners for server communication - **Optimistic Caching**: Local data caching for instant UI updates @@ -182,6 +195,7 @@ Client-side SQF addon that provides: - **Input Validation**: Client-side validation before server requests The client mod communicates with the server using **CBA Events**, ensuring: + - No direct extension calls from clients (security) - Event-driven architecture for scalability - Automatic state synchronization across all clients @@ -190,28 +204,32 @@ The client mod communicates with the server using **CBA Events**, ensuring: ## Available Commands ### Actor Commands -| Command | Description | -|---------|-------------| -| `actor:get` | Retrieve actor data by UID | -| `actor:create` | Create a new actor | -| `actor:update` | Update actor fields | -| `actor:exists` | Check if actor exists | -| `actor:delete` | Delete actor data | + +| Command | Description | +| -------------- | -------------------------- | +| `actor:get` | Retrieve actor data by UID | +| `actor:create` | Create a new actor | +| `actor:update` | Update actor fields | +| `actor:exists` | Check if actor exists | +| `actor:delete` | Delete actor data | ### Organization Commands -| Command | Description | -|---------|-------------| -| `org:get` | Retrieve organization data | -| `org:create` | Create a new organization | -| `org:update` | Update organization fields | -| `org:exists` | Check if organization exists | -| `org:delete` | Delete organization | -| `org:add_member` | Add member to organization | + +| Command | Description | +| ------------------- | ------------------------------- | +| `org:get` | Retrieve organization data | +| `org:create` | Create a new organization | +| `org:update` | Update organization fields | +| `org:exists` | Check if organization exists | +| `org:delete` | Delete organization | +| `org:add_member` | Add member to organization | | `org:remove_member` | Remove member from organization | -| `org:get_members` | Get all organization members | +| `org:get_members` | Get all organization members | ### Redis Operations + Direct Redis operations for advanced use cases: + - **Common**: Key-value operations (set, get, incr, decr, del) - **Hash**: Structured data (hset, hget, hgetall, hdel) - **List**: Ordered collections (lpush, rpush, lrange, lpop, rpop) @@ -264,6 +282,7 @@ if (_response find "Error:" == 0) then { ## Logging Logs are automatically created in `@forge_server/logs/`: + - `actor.log` - Actor operations - `org.log` - Organization operations - `redis.log` - Redis connection and operations diff --git a/arma/client/.github/CONTRIBUTING.md b/arma/client/.github/CONTRIBUTING.md index b7b4679..a378b46 100644 --- a/arma/client/.github/CONTRIBUTING.md +++ b/arma/client/.github/CONTRIBUTING.md @@ -1,12 +1,17 @@ # Contributing Setup & Guidelines ## Setting up the Development Environment + ### 1. Clone the repository from GitHub + ### 2. Install HEMTT + The latest version of HEMTT can be installed by running: + ```cmd winget install hemtt ``` ## Coding Guidelines + This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines). diff --git a/arma/client/.github/ISSUE_TEMPLATE/bug-report.md b/arma/client/.github/ISSUE_TEMPLATE/bug-report.md index d4c384f..2e818b5 100644 --- a/arma/client/.github/ISSUE_TEMPLATE/bug-report.md +++ b/arma/client/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,25 +1,31 @@ --- name: Bug report about: Create a bug report to help us improve -title: '' +title: "" labels: kind/bug --- ## Describe the bug + A clear and concise description of what the bug is. ## To reproduce + Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error ## Expected behavior + A clear and concise description of what you expected to happen. ## Attachments + If applicable, add screenshots or RPT logs to help explain your problem. ## Additional context + Add any other context about the problem here. diff --git a/arma/client/.github/ISSUE_TEMPLATE/feature-request.md b/arma/client/.github/ISSUE_TEMPLATE/feature-request.md index 709ee6c..7bd655d 100644 --- a/arma/client/.github/ISSUE_TEMPLATE/feature-request.md +++ b/arma/client/.github/ISSUE_TEMPLATE/feature-request.md @@ -1,15 +1,18 @@ --- name: Feature Request about: Suggest a feature to be added -title: '' +title: "" labels: kind/feature-request --- ## Describe the feature that you would like + A clear and concise description of the feature you'd want. ## Possible alternatives + Possible alternatives to your suggestion. ## Additional context + Add any other context about the feature here. diff --git a/arma/client/.github/PULL_REQUEST_TEMPLATE.md b/arma/client/.github/PULL_REQUEST_TEMPLATE.md index 6f72c35..1721684 100644 --- a/arma/client/.github/PULL_REQUEST_TEMPLATE.md +++ b/arma/client/.github/PULL_REQUEST_TEMPLATE.md @@ -1,12 +1,16 @@ **When merged this pull request will:** + - Describe what this pull request will do - Each change in a separate line ### Important + - [ ] If the contribution affects [the documentation](../docs), please include your changes in this pull request. - [ ] [Development Guidelines](https://github.com/IDSolutions/MOD_REPO/blob/main/.github/CONTRIBUTING.md) are read, understood and applied. - [ ] Title of this PR uses our standard template `Component - Add|Fix|Improve|Change|Make|Remove {changes}`. + ### Known Issues + - [ ] Issue diff --git a/arma/client/.github/workflows/check.yml b/arma/client/.github/workflows/check.yml index 9d2f654..abb328f 100644 --- a/arma/client/.github/workflows/check.yml +++ b/arma/client/.github/workflows/check.yml @@ -12,17 +12,17 @@ jobs: validate: runs-on: ubuntu-latest steps: - - name: Checkout the source code - uses: actions/checkout@v4 + - name: Checkout the source code + uses: actions/checkout@v4 - - name: Validate Config - run: python tools/config_style_checker.py - - name: Check for BOM - uses: arma-actions/bom-check@master - with: - path: "addons" + - name: Validate Config + run: python tools/config_style_checker.py + - name: Check for BOM + uses: arma-actions/bom-check@master + with: + path: "addons" - - name: Setup HEMTT - uses: arma-actions/hemtt@v1 - - name: Run HEMTT check - run: hemtt check --pedantic + - name: Setup HEMTT + uses: arma-actions/hemtt@v1 + - name: Run HEMTT check + run: hemtt check --pedantic diff --git a/arma/client/.gitignore b/arma/client/.gitignore index b786b16..41f642f 100644 --- a/arma/client/.gitignore +++ b/arma/client/.gitignore @@ -3,6 +3,7 @@ hemtt.exe .hemtt/missions/~* .hemttout/ releases/ +.hemttprivatekey # Textures Exports/ diff --git a/arma/client/LICENSE.md b/arma/client/LICENSE.md index 659cbdc..0cb1a9b 100644 --- a/arma/client/LICENSE.md +++ b/arma/client/LICENSE.md @@ -6,10 +6,10 @@ PLEASE, NOTE THAT THIS SUMMARY HAS NO LEGAL EFFECT AND IS ONLY OF AN INFORMATORY With this licence you are free to adapt (i.e. modify, rework or update) and share (i.e. copy, distribute or transmit) the material under the following conditions: -* **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material). -* **Noncommercial** - You may not use this material for any commercial purposes. -* **Arma Only** - You may not convert or adapt this material to be used in other games than Arma. -* **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license. +- **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material). +- **Noncommercial** - You may not use this material for any commercial purposes. +- **Arma Only** - You may not convert or adapt this material to be used in other games than Arma. +- **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license. --- @@ -97,7 +97,7 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your 2. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 2. upon express reinstatement by the Licensor. - For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. + For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. 3. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. 4. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. @@ -116,4 +116,4 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your ### Bohemia Interactive Notices 1. Bohemia Interactive a.s. is not a party to this License, and makes no warranty whatsoever in connection with the Licensed Material. Bohemia Interactive a.s. will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, Bohemia Interactive a.s. may elect to apply the Public License to material it publishes and in those instances it becomes the "Licensor". -2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s. \ No newline at end of file +2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s. diff --git a/arma/client/README.md b/arma/client/README.md index f65d2bd..7d8932d 100644 --- a/arma/client/README.md +++ b/arma/client/README.md @@ -18,10 +18,13 @@ The project is entirely **open-source** and any contributions are welcome. ## Core Features + - Feature ## Contributing + For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md). ## License + Forge Client is licensed under [APL-SA](./LICENSE.md). diff --git a/arma/client/addons/actor/README.md b/arma/client/addons/actor/README.md index 9dcb48a..e83012b 100644 --- a/arma/client/addons/actor/README.md +++ b/arma/client/addons/actor/README.md @@ -1,4 +1,3 @@ -forge_client_actor -=================== +# forge_client_actor Description for this addon diff --git a/arma/client/addons/actor/XEH_postInitClient.sqf b/arma/client/addons/actor/XEH_postInitClient.sqf index df53600..12c69b1 100644 --- a/arma/client/addons/actor/XEH_postInitClient.sqf +++ b/arma/client/addons/actor/XEH_postInitClient.sqf @@ -23,7 +23,7 @@ player addEventHandler ["Respawn", { [SRPC(economy,onRespawn), [_unit, _corpse, _uid]] call CFUNC(serverEvent); }]; -if (isNil QGVAR(ActorClass)) then { [] call FUNC(initActorClass); }; +if (isNil QGVAR(ActorClass)) then { call FUNC(initActorClass); }; [QGVAR(initActor), { GVAR(ActorClass) call ["init", []]; diff --git a/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf b/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf index 415a5f2..869b624 100644 --- a/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/actor/functions/fnc_handleUIEvents.sqf @@ -1,19 +1,25 @@ #include "..\script_component.hpp" /* + * File: fnc_handleUIEvents.sqf * Author: IDSolutions + * Date: 2026-01-28 + * Last Update: 2026-02-17 + * Public: No + * + * Description: * Handles the UI events. * * Arguments: - * None + * 0: [CONTROL] - The control that triggered the event + * 1: [BOOL] - Whether the event is from a confirm dialog + * 2: [STRING] - The message containing the event data * * Return Value: - * None + * UI events handled [BOOL] * * Example: - * [] call forge_client_actor_fnc_handleUIEvents; - * - * Public: No + * call forge_client_actor_fnc_handleUIEvents; */ params ["_control", "_isConfirmDialog", "_message"]; @@ -21,27 +27,25 @@ params ["_control", "_isConfirmDialog", "_message"]; private _alert = fromJSON _message; private _event = _alert get "event"; private _data = _alert get "data"; -private _display = displayChild findDisplay 46; diag_log format ["[FORGE:Client:Actor] Handling UI event: %1 with data: %2", _event, _data]; switch (_event) do { case "actor::get::actions": { GVAR(ActorClass) call ["getNearbyActions", [_control]]; }; + case "actor::close::menu": { closeDialog 1; }; case "actor::open::atm": { [true] spawn EFUNC(bank,openUI); }; case "actor::open::bank": { [] spawn EFUNC(bank,openUI); }; - case "actor::open::device": { hint "Device interaction is not yet implemented."; }; // TODO: Implement device interaction - case "actor::open::garage": { hint "Garage interaction is not yet implemented."; }; // TODO: Implement garage interaction + case "actor::open::device": { hint "Device interaction is not yet implemented."; }; + case "actor::open::garage": { [] spawn EFUNC(garage,openUI); }; case "actor::open::vgarage": { [] spawn EFUNC(garage,openVG); }; case "actor::open::org": { [] spawn EFUNC(org,openUI); }; - case "actor::open::locker": { hint "Locker interaction is not yet implemented."; }; // TODO: Implement locker interaction - case "actor::open::vlocker": { ["Open", [false, FORGE_Locker_Box, player]] spawn BFUNC(arsenal) }; - // case "actor::open::phone": { [] spawn EFUNC(phone,openUI) }; - case "actor::open::phone": { hint "Phone interaction is not yet implemented."; }; // TODO: Implement phone interaction - case "actor::open::iplayer": { hint "Player interaction is not yet implemented." }; // TODO: Implement player interaction - case "actor::open::store": { hint "Store interaction is not yet implemented."; }; // TODO: Implement store interaction + case "actor::open::vlocker": { [FORGE_Locker_Box, player, false] spawn AFUNC(arsenal,openBox) }; + case "actor::open::phone": { hint "Phone interaction is not yet implemented."; }; + case "actor::open::iplayer": { hint "Player interaction is not yet implemented." }; + case "actor::open::store": { [] spawn EFUNC(store,openUI); }; default { hint format ["Unhandled UI event: %1", _event]; }; }; -if (_event isNotEqualTo "actor::get::actions") then { _display closeDisplay 1; }; +if (_event isNotEqualTo "actor::get::actions") then { closeDialog 1; }; true; diff --git a/arma/client/addons/actor/functions/fnc_initActorClass.sqf b/arma/client/addons/actor/functions/fnc_initActorClass.sqf index 1e008cc..729b790 100644 --- a/arma/client/addons/actor/functions/fnc_initActorClass.sqf +++ b/arma/client/addons/actor/functions/fnc_initActorClass.sqf @@ -1,56 +1,43 @@ #include "..\script_component.hpp" /* + * File: fnc_initActorClass.sqf * Author: IDSolutions - * Initializes the actor class. + * Date: 2026-01-28 + * Last Update: 2026-02-17 + * Public: Yes + * + * Description: + * Initializes the actor class for managing player data. + * Provides methods for saving, loading, and applying actor data. * * Arguments: * None * * Return Value: - * None + * Actor class object [HASHMAP OBJECT] * - * Examples: - * [] call forge_client_actor_fnc_initActorClass - * - * Public: Yes + * Example: + * call forge_client_actor_fnc_initActorClass */ #pragma hemtt ignore_variables ["_self"] -GVAR(ActorClass) = createHashMapObject [[ - ["#type", "IActorClass"], - ["#create", { +GVAR(ActorBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "ActorBaseClass"], + ["#create", compileFinal { _self set ["uid", getPlayerUID player]; _self set ["actor", createHashMap]; _self set ["isLoaded", false]; _self set ["lastSave", time]; - - private _actor = createHashMap; - _actor set ["uid", (getPlayerUID player)]; - _actor set ["name", (name player)]; - _actor set ["loadout", [[],[],[],["U_BG_Guerrilla_6_1",[]],[],[],"H_Cap_blk_ION","",[],["ItemMap","ItemGPS","ItemRadio","ItemCompass","ItemWatch",""]]]; - _actor set ["position", (getPosASL player)]; - _actor set ["direction", (getDir player)]; - _actor set ["stance", (stance player)]; - _actor set ["rank", (rank player)]; - _actor set ["state", (lifeState player)]; - _actor set ["phone_number", ""]; - _actor set ["email", ""]; - _actor set ["organization", ""]; - _actor set ["holster", true]; - - _self set ["actor", _actor]; }], - ["init", { + ["init", compileFinal { private _uid = _self get "uid"; - private _actor = _self get "actor"; - - [SRPC(actor,requestInitActor), [_uid, _actor]] call CFUNC(serverEvent); + [SRPC(actor,requestInitActor), [_uid]] call CFUNC(serverEvent); systemChat format ["Actor loaded for %1", (name player)]; diag_log "[FORGE:Client:Actor] Actor Class Initialized!"; }], - ["save", { + ["save", compileFinal { params [["_sync", false, [false]]]; private _uid = _self get "uid"; @@ -58,16 +45,12 @@ GVAR(ActorClass) = createHashMapObject [[ _self set ["lastSave", time]; }], - ["sync", { + ["sync", compileFinal { params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; private _actor = _self get "actor"; private _isLoaded = _self get "isLoaded"; - if (_data isEqualTo createHashMap) exitWith { - diag_log "[FORGE:Client:Actor] Empty data received for sync, skipping."; - }; - { _actor set [_x, _y]; @@ -89,13 +72,13 @@ GVAR(ActorClass) = createHashMapObject [[ if !(_isLoaded) then { _self set ["isLoaded", true]; }; diag_log "[FORGE:Client:Actor] Sync completed"; }], - ["get", { + ["get", compileFinal { params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; private _actor = _self get "actor"; _actor getOrDefault [_key, _default]; }], - ["applyPosition", { + ["applyPosition", compileFinal { private _position = _self call ["get", ["position", [0, 0, 0]]]; if (GVAR(enableLoc)) then { @@ -112,33 +95,30 @@ GVAR(ActorClass) = createHashMapObject [[ }; }; }], - ["applyDirection", { + ["applyDirection", compileFinal { private _direction = _self call ["get", ["direction", 0]]; - if (GVAR(enableLoc)) then { player setDir _direction; }; }], - ["applyStance", { + ["applyStance", compileFinal { private _stance = _self call ["get", ["stance", "STAND"]]; - if (GVAR(enableLoc)) then { player playAction _stance; }; }], - ["applyRank", { + ["applyRank", compileFinal { private _rank = _self call ["get", ["rank", "PRIVATE"]]; - player setUnitRank _rank; }], - ["applyLoadout", { + ["applyLoadout", compileFinal { private _loadout = _self call ["get", ["loadout", []]]; - if (GVAR(enableGear) && count _loadout > 0) then { player setUnitLoadout _loadout; }; }], - ["getNearbyActions", { + ["getNearbyActions", compileFinal { params [["_control", controlNull, [controlNull]]]; private _nearbyActions = []; { private _storeType = _x getVariable ["storeType", ""]; + private _isAtm = _x getVariable ["isAtm", false]; private _isBank = _x getVariable ["isBank", false]; private _isGarage = _x getVariable ["isGarage", false]; private _isLocker = _x getVariable ["isLocker", false]; @@ -147,18 +127,18 @@ GVAR(ActorClass) = createHashMapObject [[ private _isPlayer = _x isKindOf "Man" && isPlayer _x; if (_storeType isNotEqualTo "") then { _nearbyActions pushBack ["store", _storeType]; }; + if (_isAtm) then { _nearbyActions pushBack ["atm", true]; }; if (_isBank) then { _nearbyActions pushBack ["bank", true]; }; - if (_isLocker) then { _nearbyActions pushBack ["locker", true]; }; if (_isLocker && GVAR(enableVA)) then { _nearbyActions pushBack ["va", true]; }; if (_isGarage) then { _nearbyActions pushBack ["garage", _garageType]; }; if (_isGarage && GVAR(enableVG)) then { _nearbyActions pushBack ["vg", true]; }; if (_deviceType isNotEqualTo "") then { _nearbyActions pushBack ["device", _deviceType]; }; - if (_isPlayer) then { _nearbyActions pushBack ["player", name _x]; }; + if (_isPlayer && { _x isNotEqualTo player }) then { _nearbyActions pushBack ["player", name _x]; }; } forEach (player nearObjects 5); _control ctrlWebBrowserAction ["ExecJS", format ["updateAvailableActions(%1)", (toJSON _nearbyActions)]]; }] -]]; +]; -SETVAR(player,FORGE_ActorClass,GVAR(ActorClass)); +GVAR(ActorClass) = createHashMapObject [GVAR(ActorBaseClass)]; GVAR(ActorClass) diff --git a/arma/client/addons/actor/functions/fnc_openUI.sqf b/arma/client/addons/actor/functions/fnc_openUI.sqf index 3517f5c..ad36acf 100644 --- a/arma/client/addons/actor/functions/fnc_openUI.sqf +++ b/arma/client/addons/actor/functions/fnc_openUI.sqf @@ -1,23 +1,27 @@ #include "..\script_component.hpp" /* + * File: fnc_openUI.sqf * Author: IDSolutions + * Date: 2026-01-28 + * Last Update: 2026-01-30 + * Public: No + * + * Description: * Opens the player interaction interface. * * Arguments: * None * * Return Value: - * None + * UI opened [BOOL] * * Example: - * [] call forge_client_actor_fnc_openUI; - * - * Public: No + * call forge_client_actor_fnc_openUI; */ -private _display = (findDisplay 46) createDisplay "RscActorMenu"; -private _ctrl = (_display displayCtrl 1001); +private _display = createDialog ["RscActorMenu", true]; +private _ctrl = _display displayCtrl 1001; _ctrl ctrlAddEventHandler ["JSDialog", { params ["_control", "_isConfirmDialog", "_message"]; diff --git a/arma/client/addons/actor/ui/_site/index.html b/arma/client/addons/actor/ui/_site/index.html index 8b482e8..381988c 100644 --- a/arma/client/addons/actor/ui/_site/index.html +++ b/arma/client/addons/actor/ui/_site/index.html @@ -1,70 +1,37 @@ - - - - - Interaction Menu - - + - - - - -
-
-
- -
-
-
- - - - + const script = document.createElement("script"); + script.text = js; + document.head.appendChild(script); + }); + + + +
+ + diff --git a/arma/client/addons/actor/ui/_site/script.js b/arma/client/addons/actor/ui/_site/script.js index 88b4334..62fcf3a 100644 --- a/arma/client/addons/actor/ui/_site/script.js +++ b/arma/client/addons/actor/ui/_site/script.js @@ -1,12 +1,66 @@ /** - * Redux-like Pattern for Actor Menu Management + * Interaction Menu - Modern UI Implementation + * Uses vanilla JS with React-like patterns and Redux-like state management */ +//============================================================================= +// #region LIBRARY - DOM Helper & State Management +//============================================================================= + +// Helper to create DOM elements (React-like createElement) +function h(tag, props = {}, ...children) { + const el = document.createElement(tag); + + if (props) { + Object.entries(props).forEach(([key, value]) => { + if (key.startsWith("on") && typeof value === "function") { + el.addEventListener(key.substring(2).toLowerCase(), value); + } else if (key === "className") { + el.className = value; + } else if (key === "style" && typeof value === "object") { + Object.assign(el.style, value); + } else { + el.setAttribute(key, value); + } + }); + } + + children.forEach((child) => { + if (typeof child === "string" || typeof child === "number") { + el.appendChild(document.createTextNode(child)); + } else if (child instanceof Node) { + el.appendChild(child); + } else if (Array.isArray(child)) { + child.forEach((c) => { + if (c instanceof Node) el.appendChild(c); + }); + } + }); + + return el; +} + +// Simple Rendering Logic +let _rootContainer = null; +let _rootComponent = null; + +function render(component, container) { + _rootContainer = container; + _rootComponent = component; + _render(); +} + +function _render() { + if (_rootContainer && _rootComponent) { + _rootContainer.innerHTML = ""; + _rootContainer.appendChild(_rootComponent()); + } +} + //============================================================================= // #region ACTIONS //============================================================================= -// Action Types const ActionTypes = { SET_AVAILABLE_ACTIONS: "SET_AVAILABLE_ACTIONS", SET_MENU_ITEMS: "SET_MENU_ITEMS", @@ -15,7 +69,6 @@ const ActionTypes = { CLEAR_ACTIONS: "CLEAR_ACTIONS", }; -// Action Creators const actions = { setAvailableActions: (actionTypes) => ({ type: ActionTypes.SET_AVAILABLE_ACTIONS, @@ -47,84 +100,91 @@ const actions = { //============================================================================= const baseMenuItems = [ - { - id: "atm", - title: "ATM", - description: "Access the ATM", - icon: "", - action: "actor::open::atm", - }, - { - id: "bank", - title: "Banking Services", - description: "Access your bank account and manage finances", - icon: "", - action: "actor::open::bank", - }, { id: "phone", - title: "Personal Phone", + title: "Phone", description: "Access and manage your personal phone", - icon: "", action: "actor::open::phone", }, { id: "org", - title: "Organization Dashboard", + title: "Organization", description: "View and manage your organization data", - icon: "", action: "actor::open::org", }, + { + id: "store", + title: "Store", + description: "Browse and purchase items from the store", + action: "actor::open::store", + }, ]; const actionDefinitions = { + atm: { + id: "atm", + title: "ATM", + description: "Access the ATM", + action: "actor::open::atm", + }, + bank: { + id: "bank", + title: "Bank", + description: "Access your bank account and manage finances", + action: "actor::open::bank", + }, + phone: { + id: "phone", + title: "Phone", + description: "Access and manage your personal phone", + action: "actor::open::phone", + }, + org: { + id: "org", + title: "Organization", + description: "View and manage your organization data", + action: "actor::open::org", + }, + store: { + id: "store", + title: "Store", + description: "Browse and purchase items from the store", + action: "actor::open::store", + }, device: { id: "device", - title: "Device Interaction", + title: "Device", description: "Manage devices and settings", - icon: "", action: "actor::open::device", }, garage: { id: "garage", - title: "Vehicle Garage", + title: "Garage", description: "Access and manage your vehicle collection", - icon: "", action: "actor::open::garage", }, - locker: { - id: "locker", - title: "Locker", - description: "Access your personal locker for storage", - icon: "", - action: "actor::open::locker", - }, player: { id: "player", - title: "Player Interaction", + title: "Player", description: "Interact with player-specific actions", - icon: "", action: "actor::open::iplayer", }, store: { id: "store", title: "Store", description: "Browse and purchase items from the store", - icon: "", action: "actor::open::store", }, va: { id: "va", - title: "Virtual Arsenal", + title: "Arsenal", description: "Access your virtual arsenal", - icon: "", action: "actor::open::vlocker", }, vg: { id: "vg", - title: "Virtual Garage", + title: "V. Garage", description: "Access your virtual garage", - icon: "", action: "actor::open::vgarage", }, }; @@ -141,7 +201,6 @@ function actorReducer(state = initialState, action) { case ActionTypes.SET_AVAILABLE_ACTIONS: const newMenuItems = [...state.baseMenuItems]; - // Process available actions const actionArray = Array.isArray(action.payload) ? action.payload : []; @@ -225,6 +284,7 @@ class Store { console.log("Dispatching action:", action); this.state = this.reducer(this.state, action); this.listeners.forEach((listener) => listener(this.state)); + _render(); // Re-render on state change } subscribe(listener) { @@ -235,7 +295,6 @@ class Store { } } -// Create store instance const store = new Store(actorReducer, initialState); //============================================================================= @@ -253,94 +312,149 @@ const selectors = { }; //============================================================================= -// #region UI COMPONENTS (Redux-connected) +// #region UI COMPONENTS //============================================================================= -class ActorUI { - constructor(store) { - this.store = store; - this.unsubscribe = null; +// Tooltip state +let tooltipEl = null; + +function createTooltip() { + if (!tooltipEl) { + tooltipEl = h( + "div", + { className: "radial-tooltip" }, + h("div", { className: "tooltip-title" }), + h("div", { className: "tooltip-description" }), + ); + document.body.appendChild(tooltipEl); } + return tooltipEl; +} - init() { - console.log("ActorUI initializing..."); +function showTooltip(item, x, y) { + const tooltip = createTooltip(); + tooltip.querySelector(".tooltip-title").textContent = item.title; + tooltip.querySelector(".tooltip-description").textContent = + item.description; + tooltip.style.left = `${x + 15}px`; + tooltip.style.top = `${y + 10}px`; + tooltip.classList.add("visible"); +} - // Subscribe to state changes - this.unsubscribe = this.store.subscribe((state) => { - this.render(state); - }); - - // Initial render - this.render(this.store.getState()); - - // Request initial data - this.requestInitialData(); - - console.log("ActorUI initialized successfully"); +function hideTooltip() { + if (tooltipEl) { + tooltipEl.classList.remove("visible"); } +} - render(state) { - this.updateMenuDisplay(state); - } +function RadialItem({ item, index, total, onClick }) { + const menuRadius = 160; + const itemSize = 80; - updateMenuDisplay(state) { - const grid = document.getElementById("menuGrid"); - if (!grid) { - console.error("Menu grid element not found"); - return; + // Calculate position in circle + const angleStep = (2 * Math.PI) / total; + const angle = angleStep * index - Math.PI / 2; // Start from top + + const centerX = menuRadius + itemSize / 2; + const centerY = menuRadius + itemSize / 2; + + const x = centerX + menuRadius * Math.cos(angle) - itemSize / 2; + const y = centerY + menuRadius * Math.sin(angle) - itemSize / 2; + + const el = h( + "div", + { + className: "radial-item", + style: { + left: `${x}px`, + top: `${y}px`, + }, + onClick: () => onClick(item), + }, + h("div", { className: "radial-item-title" }, item.title), + ); + + // Add tooltip events + el.addEventListener("mouseenter", (e) => + showTooltip(item, e.clientX, e.clientY), + ); + el.addEventListener("mousemove", (e) => { + if (tooltipEl && tooltipEl.classList.contains("visible")) { + tooltipEl.style.left = `${e.clientX + 15}px`; + tooltipEl.style.top = `${e.clientY + 10}px`; } + }); + el.addEventListener("mouseleave", hideTooltip); - // Clear existing menu items - grid.innerHTML = ""; + return el; +} - // Render menu items - const menuItems = selectors.getMenuItems(state); - menuItems.forEach((item) => { - const menuItem = document.createElement("div"); - menuItem.className = "neu-menu-item"; - menuItem.setAttribute("data-action", item.action); - menuItem.innerHTML = ` -
${item.icon}
-
${item.title}
-
${item.description}
- `; - menuItem.addEventListener("click", () => - this.handleMenuItemClick(item), - ); +function RadialCenter({ onClose }) { + return h( + "div", + { + className: "radial-center", + onClick: onClose, + }, + h("div", { className: "center-label" }, "Close"), + ); +} - grid.appendChild(menuItem); - }); +function RadialMenu() { + const state = store.getState(); + const menuItems = selectors.getMenuItems(state); - console.log(`Rendered ${menuItems.length} menu items`); - } - - handleMenuItemClick(item) { + const handleItemClick = (item) => { console.log("Menu item clicked:", item); const alert = { event: item.action, data: {}, }; - A3API.SendAlert(JSON.stringify(alert)); - } + if (typeof A3API !== "undefined") { + A3API.SendAlert(JSON.stringify(alert)); + } + }; - requestInitialData() { - console.log("Requesting initial actor data..."); + const handleClose = () => { + console.log("Close menu requested"); const alert = { - event: "actor::get::actions", + event: "actor::close::menu", data: {}, }; - A3API.SendAlert(JSON.stringify(alert)); + if (typeof A3API !== "undefined") { + A3API.SendAlert(JSON.stringify(alert)); + } + }; + + if (menuItems.length === 0) { + return h( + "div", + { className: "empty-state" }, + h("p", null, "No actions available"), + ); } - destroy() { - if (this.unsubscribe) { - this.unsubscribe(); - } - } + return h( + "div", + { className: "radial-menu" }, + RadialCenter({ onClose: handleClose }), + menuItems.map((item, index) => + RadialItem({ + item, + index, + total: menuItems.length, + onClick: handleItemClick, + }), + ), + ); +} + +function App() { + return RadialMenu(); } //============================================================================= -// #region DATA HANDLERS (Redux-connected) +// #region DATA HANDLERS (A3API Integration) //============================================================================= function updateAvailableActions(actionTypes) { @@ -354,78 +468,45 @@ function handleGetActionsResponse(data) { } //============================================================================= -// #region ACTION HANDLERS +// #region INITIALIZATION //============================================================================= -function handleMenuItemClick(item) { - console.log("Legacy menu item click handler:", item); - const alert = { - event: item.action, - data: {}, - }; - A3API.SendAlert(JSON.stringify(alert)); -} +let initialized = false; -//============================================================================= -// #region INITIALIZATION FUNCTIONS -//============================================================================= - -// Global flag to prevent double initialization -let actorUIInitialized = false; - -/** - * Initialize the actor interface - called from HTML after script loads - */ function initializeMenu() { console.log("initializeMenu() called"); - if (actorUIInitialized) { - console.log("ActorUI already initialized, skipping..."); + if (initialized) { + console.log("Menu already initialized, skipping..."); return; } - // Check if DOM is ready - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", () => { - if (!actorUIInitialized) { - console.log("DOM loaded, initializing ActorUI..."); - window.actorUI = new ActorUI(store); - window.actorUI.init(); - actorUIInitialized = true; - } - }); + const root = document.getElementById("app"); + if (root) { + render(App, root); + initialized = true; + console.log("Interaction menu initialized successfully"); + + // Request initial data from A3API + if (typeof A3API !== "undefined") { + const alert = { + event: "actor::get::actions", + data: {}, + }; + A3API.SendAlert(JSON.stringify(alert)); + } } else { - // DOM is already ready - console.log("DOM already ready, initializing ActorUI..."); - window.actorUI = new ActorUI(store); - window.actorUI.init(); - actorUIInitialized = true; + console.error("Root element #app not found"); } } -//============================================================================= -// #region GLOBAL VARIABLES -//============================================================================= - -// Make actorUI globally accessible -let actorUI; - -// Auto-initialize if DOM is already loaded when script executes +// Auto-initialize based on DOM state if (document.readyState !== "loading") { console.log("Script loaded after DOM ready, auto-initializing..."); - if (!actorUIInitialized) { - actorUI = new ActorUI(store); - actorUI.init(); - actorUIInitialized = true; - } + initializeMenu(); } else { - // Wait for DOM to be ready document.addEventListener("DOMContentLoaded", () => { - if (!actorUIInitialized) { - console.log("DOM loaded, initializing ActorUI..."); - actorUI = new ActorUI(store); - actorUI.init(); - actorUIInitialized = true; - } + console.log("DOM loaded, initializing menu..."); + initializeMenu(); }); } diff --git a/arma/client/addons/actor/ui/_site/script.js.bak b/arma/client/addons/actor/ui/_site/script.js.bak deleted file mode 100644 index 288057e..0000000 --- a/arma/client/addons/actor/ui/_site/script.js.bak +++ /dev/null @@ -1,417 +0,0 @@ -/** - * Redux-like Pattern for Actor Menu Management - */ - -//============================================================================= -// #region ACTIONS -//============================================================================= - -// Action Types -const ActionTypes = { - SET_AVAILABLE_ACTIONS: "SET_AVAILABLE_ACTIONS", - SET_MENU_ITEMS: "SET_MENU_ITEMS", - ADD_ACTION: "ADD_ACTION", - REMOVE_ACTION: "REMOVE_ACTION", - CLEAR_ACTIONS: "CLEAR_ACTIONS", -}; - -// Action Creators -const actions = { - setAvailableActions: (actionTypes) => ({ - type: ActionTypes.SET_AVAILABLE_ACTIONS, - payload: actionTypes, - }), - - setMenuItems: (menuItems) => ({ - type: ActionTypes.SET_MENU_ITEMS, - payload: menuItems, - }), - - addAction: (actionType) => ({ - type: ActionTypes.ADD_ACTION, - payload: actionType, - }), - - removeAction: (actionType) => ({ - type: ActionTypes.REMOVE_ACTION, - payload: actionType, - }), - - clearActions: () => ({ - type: ActionTypes.CLEAR_ACTIONS, - }), -}; - -//============================================================================= -// #region REDUCER -//============================================================================= - -const baseMenuItems = [ - { - id: "bank", - title: "Banking Services", - description: "Access your bank account and manage finances", - icon: "", - action: "actor::open::bank", - }, - { - id: "phone", - title: "Personal Phone", - description: "Access and manage your personal phone", - icon: "", - action: "actor::open::phone", - }, -]; - -const actionDefinitions = { - device: { - id: "device", - title: "Device Interaction", - description: "Manage devices and settings", - icon: "", - action: "actor::open::device", - }, - garage: { - id: "garage", - title: "Vehicle Garage", - description: "Access and manage your vehicle collection", - icon: "", - action: "actor::open::garage", - }, - locker: { - id: "locker", - title: "Locker", - description: "Access your personal locker for storage", - icon: "", - action: "actor::open::locker", - }, - player: { - id: "player", - title: "Player Interaction", - description: "Interact with player-specific actions", - icon: "", - action: "actor::open::iplayer", - }, - store: { - id: "store", - title: "Store", - description: "Browse and purchase items from the store", - icon: "", - action: "actor::open::store", - }, - va: { - id: "va", - title: "Virtual Arsenal", - description: "Access your virtual arsenal", - icon: "", - action: "actor::open::arsenal", - }, - vg: { - id: "vg", - title: "Virtual Garage", - description: "Access your virtual garage", - icon: "", - action: "actor::open::vgarage", - }, -}; - -const initialState = { - availableActions: [], - menuItems: [...baseMenuItems], - baseMenuItems: [...baseMenuItems], - actionDefinitions: { ...actionDefinitions }, -}; - -function actorReducer(state = initialState, action) { - switch (action.type) { - case ActionTypes.SET_AVAILABLE_ACTIONS: - const newMenuItems = [...state.baseMenuItems]; - - // Process available actions - const actionArray = Array.isArray(action.payload) - ? action.payload - : []; - actionArray.forEach((actionItem) => { - if (Array.isArray(actionItem) && actionItem.length === 2) { - const [type, value] = actionItem; - const definition = state.actionDefinitions[value]; - if (definition) { - newMenuItems.push(definition); - } else { - console.warn( - `No definition found for: ${type} - ${value}`, - ); - } - } else { - console.warn("Invalid action format:", actionItem); - } - }); - - return { - ...state, - availableActions: action.payload, - menuItems: newMenuItems, - }; - - case ActionTypes.SET_MENU_ITEMS: - return { - ...state, - menuItems: action.payload, - }; - - case ActionTypes.ADD_ACTION: - const definition = state.actionDefinitions[action.payload]; - if ( - definition && - !state.menuItems.find((item) => item.id === definition.id) - ) { - return { - ...state, - menuItems: [...state.menuItems, definition], - }; - } - return state; - - case ActionTypes.REMOVE_ACTION: - return { - ...state, - menuItems: state.menuItems.filter( - (item) => item.id !== action.payload, - ), - }; - - case ActionTypes.CLEAR_ACTIONS: - return { - ...state, - availableActions: [], - menuItems: [...state.baseMenuItems], - }; - - default: - return state; - } -} - -//============================================================================= -// #region STORE -//============================================================================= - -class Store { - constructor(reducer, initialState) { - this.reducer = reducer; - this.state = initialState; - this.listeners = []; - } - - getState() { - return this.state; - } - - dispatch(action) { - console.log("Dispatching action:", action); - this.state = this.reducer(this.state, action); - this.listeners.forEach((listener) => listener(this.state)); - } - - subscribe(listener) { - this.listeners.push(listener); - return () => { - this.listeners = this.listeners.filter((l) => l !== listener); - }; - } -} - -// Create store instance -const store = new Store(actorReducer, initialState); - -//============================================================================= -// #region SELECTORS -//============================================================================= - -const selectors = { - getMenuItems: (state) => state.menuItems, - getAvailableActions: (state) => state.availableActions, - getBaseMenuItems: (state) => state.baseMenuItems, - getActionDefinitions: (state) => state.actionDefinitions, - getMenuItemById: (state, id) => - state.menuItems.find((item) => item.id === id), - getMenuItemsCount: (state) => state.menuItems.length, -}; - -//============================================================================= -// #region UI COMPONENTS (Redux-connected) -//============================================================================= - -class ActorUI { - constructor(store) { - this.store = store; - this.unsubscribe = null; - } - - init() { - console.log("ActorUI initializing..."); - - // Subscribe to state changes - this.unsubscribe = this.store.subscribe((state) => { - this.render(state); - }); - - // Initial render - this.render(this.store.getState()); - - // Request initial data - this.requestInitialData(); - - console.log("ActorUI initialized successfully"); - } - - render(state) { - this.updateMenuDisplay(state); - } - - updateMenuDisplay(state) { - const grid = document.getElementById("menuGrid"); - if (!grid) { - console.error("Menu grid element not found"); - return; - } - - // Clear existing menu items - grid.innerHTML = ""; - - // Render menu items - const menuItems = selectors.getMenuItems(state); - menuItems.forEach((item) => { - const menuItem = document.createElement("div"); - menuItem.className = "neu-menu-item"; - menuItem.setAttribute("data-action", item.action); - menuItem.innerHTML = ` -
${item.icon}
-
${item.title}
-
${item.description}
- `; - menuItem.addEventListener("click", () => - this.handleMenuItemClick(item), - ); - - grid.appendChild(menuItem); - }); - - console.log(`Rendered ${menuItems.length} menu items`); - } - - handleMenuItemClick(item) { - console.log("Menu item clicked:", item); - const alert = { - event: item.action, - data: {}, - }; - A3API.SendAlert(JSON.stringify(alert)); - } - - requestInitialData() { - console.log("Requesting initial actor data..."); - const alert = { - event: "actor::get::actions", - data: {}, - }; - A3API.SendAlert(JSON.stringify(alert)); - } - - destroy() { - if (this.unsubscribe) { - this.unsubscribe(); - } - } -} - -//============================================================================= -// #region DATA HANDLERS (Redux-connected) -//============================================================================= - -function updateAvailableActions(actionTypes) { - console.log("Updating available actions:", actionTypes); - store.dispatch(actions.setAvailableActions(actionTypes)); -} - -function handleGetActionsResponse(data) { - console.log("Received actions data:", data); - store.dispatch(actions.setAvailableActions(data)); -} - -//============================================================================= -// #region ACTION HANDLERS -//============================================================================= - -function handleMenuItemClick(item) { - console.log("Legacy menu item click handler:", item); - const alert = { - event: item.action, - data: {}, - }; - A3API.SendAlert(JSON.stringify(alert)); -} - -//============================================================================= -// #region INITIALIZATION FUNCTIONS -//============================================================================= - -// Global flag to prevent double initialization -let actorUIInitialized = false; - -/** - * Initialize the actor interface - called from HTML after script loads - */ -function initializeMenu() { - console.log("initializeMenu() called"); - - if (actorUIInitialized) { - console.log("ActorUI already initialized, skipping..."); - return; - } - - // Check if DOM is ready - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", () => { - if (!actorUIInitialized) { - console.log("DOM loaded, initializing ActorUI..."); - window.actorUI = new ActorUI(store); - window.actorUI.init(); - actorUIInitialized = true; - } - }); - } else { - // DOM is already ready - console.log("DOM already ready, initializing ActorUI..."); - window.actorUI = new ActorUI(store); - window.actorUI.init(); - actorUIInitialized = true; - } -} - -//============================================================================= -// #region GLOBAL VARIABLES -//============================================================================= - -// Make actorUI globally accessible -let actorUI; - -// Auto-initialize if DOM is already loaded when script executes -if (document.readyState !== "loading") { - console.log("Script loaded after DOM ready, auto-initializing..."); - if (!actorUIInitialized) { - actorUI = new ActorUI(store); - actorUI.init(); - actorUIInitialized = true; - } -} else { - // Wait for DOM to be ready - document.addEventListener("DOMContentLoaded", () => { - if (!actorUIInitialized) { - console.log("DOM loaded, initializing ActorUI..."); - actorUI = new ActorUI(store); - actorUI.init(); - actorUIInitialized = true; - } - }); -} diff --git a/arma/client/addons/actor/ui/_site/store.css b/arma/client/addons/actor/ui/_site/store.css deleted file mode 100644 index 4974a9b..0000000 --- a/arma/client/addons/actor/ui/_site/store.css +++ /dev/null @@ -1,567 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - height: 100vh; - width: 100vw; - background: rgba(0, 0, 0, 0.7); - font-family: Arial, sans-serif; - color: rgba(200, 220, 240, 0.95); - overflow: hidden; -} - -.store-container { - height: 100vh; - width: 100vw; - padding: 2rem; - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -/* Header Section */ -.store-header { - display: flex; - align-items: center; - gap: 1.5rem; - padding: 1.25rem 1.5rem; - background: rgba(15, 20, 30, 0.9); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - box-shadow: - 0 0 20px rgba(100, 150, 200, 0.15), - 0 4px 16px rgba(0, 0, 0, 0.8); -} - -.store-logo { - width: 60px; - height: 60px; - background: rgba(20, 30, 45, 0.8); - border: 2px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; -} - -.logo-icon { - font-size: 2rem; -} - -.store-info { - flex: 1; -} - -.store-title { - font-size: 1.5rem; - font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; - color: rgba(200, 220, 255, 1); - margin-bottom: 0.25rem; -} - -.store-subtitle { - font-size: 0.875rem; - color: rgba(140, 160, 180, 0.8); - letter-spacing: 0.5px; -} - -.balance-display { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.25rem; - padding: 0.75rem 1.25rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 200, 150, 0.4); - border-radius: 4px; -} - -.balance-label { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(140, 160, 180, 0.8); -} - -.balance-amount { - font-size: 1.25rem; - font-weight: 600; - color: rgba(100, 200, 150, 1); -} - -.header-actions { - display: flex; - gap: 0.75rem; -} - -.action-btn { - padding: 0.625rem 1.25rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.5px; - cursor: pointer; - transition: all 0.15s ease; -} - -.action-btn:hover { - background: rgba(30, 45, 70, 0.9); - border-color: rgba(150, 200, 255, 0.7); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.2), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.cart-btn { - display: flex; - align-items: center; - gap: 0.5rem; - position: relative; -} - -.cart-icon { - font-size: 1.25rem; -} - -.cart-count { - min-width: 24px; - height: 24px; - padding: 0 0.5rem; - background: rgba(100, 150, 200, 0.3); - border: 1px solid rgba(100, 150, 200, 0.5); - border-radius: 12px; - display: flex; - align-items: center; - justify-content: center; - font-size: 0.75rem; - font-weight: 600; -} - -.close-btn { - border-color: rgba(200, 100, 100, 0.4); -} - -.close-btn:hover { - border-color: rgba(255, 100, 100, 0.7); - box-shadow: - 0 0 15px rgba(200, 100, 100, 0.2), - inset 0 0 20px rgba(200, 100, 100, 0.05); -} - -/* Main Content */ -.store-content { - flex: 1; - display: grid; - grid-template-columns: 250px 1fr; - gap: 1.5rem; - overflow: hidden; -} - -.store-content.cart-open { - grid-template-columns: 250px 1fr 350px; -} - -/* Panels */ -.store-panel { - background: rgba(15, 20, 30, 0.9); - border: 1px solid rgba(100, 150, 200, 0.4); - border-left: 3px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; - display: flex; - flex-direction: column; - box-shadow: - 0 0 20px rgba(100, 150, 200, 0.1), - 0 4px 16px rgba(0, 0, 0, 0.6); -} - -.panel-header { - padding: 1.25rem 1.5rem; - border-bottom: 1px solid rgba(100, 150, 200, 0.2); - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; -} - -.panel-title { - font-size: 1rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(200, 220, 255, 1); -} - -.panel-content { - flex: 1; - padding: 1.5rem; - overflow-y: auto; -} - -/* Custom Scrollbar */ -.panel-content::-webkit-scrollbar { - width: 8px; -} - -.panel-content::-webkit-scrollbar-track { - background: rgba(15, 20, 30, 0.5); - border-radius: 4px; -} - -.panel-content::-webkit-scrollbar-thumb { - background: rgba(100, 150, 200, 0.3); - border-radius: 4px; -} - -.panel-content::-webkit-scrollbar-thumb:hover { - background: rgba(100, 150, 200, 0.5); -} - -/* Search Box */ -.search-box { - flex: 1; - max-width: 300px; -} - -.search-input { - width: 100%; - padding: 0.625rem 1rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 0.875rem; - transition: all 0.15s ease; -} - -.search-input:focus { - outline: none; - border-color: rgba(150, 200, 255, 0.6); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.15), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.search-input::placeholder { - color: rgba(100, 120, 140, 0.6); -} - -/* Category List */ -.category-list { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.category-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 1rem; - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-left: 3px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - cursor: pointer; - transition: all 0.15s ease; - text-align: left; -} - -.category-item:hover { - background: rgba(30, 45, 70, 0.7); - border-left-color: rgba(150, 200, 255, 0.7); -} - -.category-item.active { - background: rgba(30, 45, 70, 0.8); - border-left-color: rgba(100, 200, 150, 0.8); - box-shadow: - 0 0 15px rgba(100, 200, 150, 0.15), - inset 0 0 20px rgba(100, 200, 150, 0.05); -} - -.category-icon { - font-size: 1.5rem; -} - -.category-name { - flex: 1; - font-size: 0.875rem; - font-weight: 500; -} - -.category-count { - padding: 0.25rem 0.5rem; - background: rgba(100, 150, 200, 0.2); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 3px; - font-size: 0.75rem; - font-weight: 600; -} - -/* Items Grid */ -.items-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 1rem; -} - -.item-card { - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-left: 3px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - padding: 1.25rem; - cursor: pointer; - transition: all 0.15s ease; - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.item-card:hover { - background: rgba(30, 45, 70, 0.7); - border-left-color: rgba(150, 200, 255, 0.7); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.15), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.item-icon { - font-size: 3rem; - text-align: center; -} - -.item-name { - font-size: 0.95rem; - font-weight: 600; - color: rgba(200, 220, 255, 1); - text-align: center; -} - -.item-description { - font-size: 0.75rem; - color: rgba(140, 160, 180, 0.85); - text-align: center; - line-height: 1.3; - min-height: 2.6rem; -} - -.item-price { - font-size: 1.125rem; - font-weight: 600; - color: rgba(100, 200, 150, 1); - text-align: center; - margin-top: auto; -} - -.item-actions { - display: flex; - gap: 0.5rem; - margin-top: 0.5rem; -} - -.add-to-cart-btn { - flex: 1; - padding: 0.625rem; - background: rgba(100, 150, 200, 0.2); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.5px; - cursor: pointer; - transition: all 0.15s ease; -} - -.add-to-cart-btn:hover { - background: rgba(100, 150, 200, 0.3); - border-color: rgba(150, 200, 255, 0.6); -} - -/* Cart Panel */ -.cart-items { - display: flex; - flex-direction: column; - gap: 0.75rem; - margin-bottom: 1.5rem; -} - -.empty-cart { - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; - padding: 3rem 1rem; -} - -.empty-icon { - font-size: 3rem; - opacity: 0.3; -} - -.empty-text { - font-size: 0.875rem; - color: rgba(140, 160, 180, 0.7); -} - -.cart-item { - padding: 1rem; - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-left: 3px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; -} - -.cart-item-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; -} - -.cart-item-name { - font-size: 0.875rem; - font-weight: 600; - color: rgba(200, 220, 255, 1); -} - -.cart-item-remove { - padding: 0.25rem 0.5rem; - background: rgba(200, 100, 100, 0.2); - border: 1px solid rgba(200, 100, 100, 0.4); - border-radius: 3px; - color: rgba(255, 150, 150, 0.9); - font-size: 0.7rem; - cursor: pointer; - transition: all 0.15s ease; -} - -.cart-item-remove:hover { - background: rgba(200, 100, 100, 0.3); -} - -.cart-item-details { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 0.8rem; - color: rgba(160, 180, 200, 0.85); -} - -.cart-item-price { - color: rgba(100, 200, 150, 1); - font-weight: 600; -} - -.clear-cart-btn { - padding: 0.5rem 0.75rem; - background: rgba(200, 100, 100, 0.2); - border: 1px solid rgba(200, 100, 100, 0.4); - border-radius: 4px; - color: rgba(255, 150, 150, 0.9); - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.5px; - cursor: pointer; - transition: all 0.15s ease; -} - -.clear-cart-btn:hover { - background: rgba(200, 100, 100, 0.3); -} - -/* Cart Summary */ -.cart-summary { - padding-top: 1.5rem; - border-top: 1px solid rgba(100, 150, 200, 0.2); -} - -.summary-row { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.75rem; -} - -.summary-label { - font-size: 0.875rem; - color: rgba(160, 180, 200, 0.85); -} - -.summary-value { - font-size: 0.95rem; - font-weight: 600; - color: rgba(200, 220, 240, 0.95); -} - -.summary-total { - padding-top: 0.75rem; - border-top: 1px solid rgba(100, 150, 200, 0.2); - margin-top: 0.5rem; -} - -.summary-total .summary-label { - font-size: 1rem; - font-weight: 600; - color: rgba(200, 220, 255, 1); -} - -.summary-total .summary-value { - font-size: 1.25rem; - color: rgba(100, 200, 150, 1); -} - -.action-btn-primary { - width: 100%; - padding: 0.875rem; - margin-top: 1rem; - background: rgba(100, 150, 200, 0.2); - border: 1px solid rgba(100, 150, 200, 0.5); -} - -.action-btn-primary:hover { - background: rgba(100, 150, 200, 0.3); - border-color: rgba(150, 200, 255, 0.7); -} - -.checkout-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.checkout-btn:disabled:hover { - background: rgba(100, 150, 200, 0.2); - border-color: rgba(100, 150, 200, 0.5); - box-shadow: none; -} - -/* Responsive adjustments */ -@media (max-width: 1400px) { - .items-grid { - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - } -} - -@media (max-width: 1200px) { - .store-content { - grid-template-columns: 1fr; - } - - .store-content.cart-open { - grid-template-columns: 1fr 350px; - } - - .categories-panel { - display: none; - } -} diff --git a/arma/client/addons/actor/ui/_site/store.html b/arma/client/addons/actor/ui/_site/store.html deleted file mode 100644 index debed7c..0000000 --- a/arma/client/addons/actor/ui/_site/store.html +++ /dev/null @@ -1,149 +0,0 @@ - - - - - - - Store - - - - - - -
- -
- -
-

Supply Store

-

Equipment & Resources

-
-
- Available Funds - $45,750 -
-
- - -
-
- - -
- -
-
-

Categories

-
-
-
- - - - - -
-
-
- - -
-
-

Available Items

- -
-
-
- -
-
-
- - - -
-
- - - - - diff --git a/arma/client/addons/actor/ui/_site/store.js b/arma/client/addons/actor/ui/_site/store.js deleted file mode 100644 index 8d85756..0000000 --- a/arma/client/addons/actor/ui/_site/store.js +++ /dev/null @@ -1,339 +0,0 @@ -/** - * Store Interface - * Handles item browsing, cart management, and purchases - */ - -// Mock data -const mockData = { - balance: 45750, - items: [ - // Weapons - { id: 1, name: "Assault Rifle", category: "weapons", icon: "🔫", description: "Standard military-grade rifle", price: 2500 }, - { id: 2, name: "Sniper Rifle", category: "weapons", icon: "🎯", description: "Long-range precision weapon", price: 4500 }, - { id: 3, name: "SMG", category: "weapons", icon: "🔫", description: "Close-quarters combat", price: 1800 }, - { id: 4, name: "Pistol", category: "weapons", icon: "🔫", description: "Sidearm backup weapon", price: 800 }, - { id: 5, name: "Shotgun", category: "weapons", icon: "🔫", description: "Close-range powerhouse", price: 1500 }, - { id: 6, name: "LMG", category: "weapons", icon: "🔫", description: "Heavy suppression weapon", price: 3500 }, - { id: 7, name: "Grenade Launcher", category: "weapons", icon: "💣", description: "Explosive ordnance", price: 5000 }, - { id: 8, name: "Rocket Launcher", category: "weapons", icon: "🚀", description: "Anti-vehicle weapon", price: 8000 }, - - // Equipment - { id: 9, name: "Body Armor", category: "equipment", icon: "🎽", description: "Ballistic protection", price: 3000 }, - { id: 10, name: "Helmet", category: "equipment", icon: "⛑️", description: "Head protection", price: 1200 }, - { id: 11, name: "Night Vision", category: "equipment", icon: "🕶️", description: "See in the dark", price: 2500 }, - { id: 12, name: "GPS Device", category: "equipment", icon: "📡", description: "Navigation system", price: 800 }, - { id: 13, name: "Radio", category: "equipment", icon: "📻", description: "Team communication", price: 600 }, - { id: 14, name: "Backpack", category: "equipment", icon: "🎒", description: "Extra storage capacity", price: 500 }, - - // Medical - { id: 15, name: "First Aid Kit", category: "medical", icon: "💊", description: "Basic medical supplies", price: 400 }, - { id: 16, name: "Med Kit", category: "medical", icon: "⚕️", description: "Advanced medical kit", price: 1000 }, - { id: 17, name: "Bandages", category: "medical", icon: "🩹", description: "Stop bleeding", price: 150 }, - { id: 18, name: "Morphine", category: "medical", icon: "💉", description: "Pain management", price: 300 }, - { id: 19, name: "Blood Bag", category: "medical", icon: "🩸", description: "Restore blood level", price: 500 }, - - // Supplies - { id: 20, name: "Ammunition Box", category: "supplies", icon: "📦", description: "Mixed ammunition", price: 800 }, - { id: 21, name: "Explosive Charges", category: "supplies", icon: "💣", description: "Demolition supplies", price: 1500 }, - { id: 22, name: "Toolkit", category: "supplies", icon: "🔧", description: "Repair equipment", price: 600 }, - { id: 23, name: "Food Rations", category: "supplies", icon: "🥫", description: "Emergency supplies", price: 200 }, - { id: 24, name: "Water Canteen", category: "supplies", icon: "🧃", description: "Hydration supply", price: 150 } - ] -}; - -// State -let cart = []; -let selectedCategory = 'all'; -let searchQuery = ''; - -// Initialize -function initStore() { - console.log('Store interface initializing...'); - - setupEventHandlers(); - renderItems(); - updateBalance(); - - console.log('Store interface initialized'); -} - -// Event Handlers -function setupEventHandlers() { - // Close button - const closeBtn = document.querySelector('.close-btn'); - if (closeBtn) { - closeBtn.addEventListener('click', () => { - console.log('Closing store...'); - sendEvent('actor::close::store', {}); - }); - } - - // Cart toggle - const cartToggle = document.getElementById('cartToggle'); - const cartPanel = document.getElementById('cartPanel'); - const storeContent = document.querySelector('.store-content'); - - if (cartToggle && cartPanel) { - cartToggle.addEventListener('click', () => { - const isOpen = cartPanel.style.display !== 'none'; - cartPanel.style.display = isOpen ? 'none' : 'flex'; - storeContent.classList.toggle('cart-open', !isOpen); - }); - } - - // Category filters - const categoryItems = document.querySelectorAll('.category-item'); - categoryItems.forEach(item => { - item.addEventListener('click', () => { - categoryItems.forEach(c => c.classList.remove('active')); - item.classList.add('active'); - selectedCategory = item.dataset.category; - renderItems(); - }); - }); - - // Search - const searchInput = document.getElementById('searchInput'); - if (searchInput) { - searchInput.addEventListener('input', (e) => { - searchQuery = e.target.value.toLowerCase(); - renderItems(); - }); - } - - // Clear cart - const clearCartBtn = document.getElementById('clearCart'); - if (clearCartBtn) { - clearCartBtn.addEventListener('click', () => { - if (confirm('Clear all items from cart?')) { - cart = []; - renderCart(); - updateCartCount(); - } - }); - } - - // Checkout - const checkoutBtn = document.getElementById('checkoutBtn'); - if (checkoutBtn) { - checkoutBtn.addEventListener('click', handleCheckout); - } -} - -// Render items -function renderItems() { - const itemsGrid = document.getElementById('itemsGrid'); - if (!itemsGrid) return; - - itemsGrid.innerHTML = ''; - - // Filter items - let filteredItems = mockData.items; - - if (selectedCategory !== 'all') { - filteredItems = filteredItems.filter(item => item.category === selectedCategory); - } - - if (searchQuery) { - filteredItems = filteredItems.filter(item => - item.name.toLowerCase().includes(searchQuery) || - item.description.toLowerCase().includes(searchQuery) - ); - } - - // Render filtered items - filteredItems.forEach(item => { - const card = document.createElement('div'); - card.className = 'item-card'; - - card.innerHTML = ` -
${item.icon}
-
${item.name}
-
${item.description}
-
$${item.price.toLocaleString()}
-
- -
- `; - - const addBtn = card.querySelector('.add-to-cart-btn'); - addBtn.addEventListener('click', (e) => { - e.stopPropagation(); - addToCart(item); - }); - - itemsGrid.appendChild(card); - }); - - console.log(`Rendered ${filteredItems.length} items`); -} - -// Cart functions -function addToCart(item) { - const existingItem = cart.find(c => c.id === item.id); - - if (existingItem) { - existingItem.quantity++; - } else { - cart.push({ ...item, quantity: 1 }); - } - - renderCart(); - updateCartCount(); - - // Show cart panel if not visible - const cartPanel = document.getElementById('cartPanel'); - const storeContent = document.querySelector('.store-content'); - if (cartPanel.style.display === 'none') { - cartPanel.style.display = 'flex'; - storeContent.classList.add('cart-open'); - } - - console.log('Added to cart:', item.name); -} - -function removeFromCart(itemId) { - cart = cart.filter(item => item.id !== itemId); - renderCart(); - updateCartCount(); -} - -function renderCart() { - const cartItems = document.getElementById('cartItems'); - if (!cartItems) return; - - cartItems.innerHTML = ''; - - if (cart.length === 0) { - cartItems.innerHTML = ` -
- 🛒 - Your cart is empty -
- `; - } else { - cart.forEach(item => { - const cartItem = document.createElement('div'); - cartItem.className = 'cart-item'; - - cartItem.innerHTML = ` -
- ${item.name} - -
-
- Qty: ${item.quantity} - $${(item.price * item.quantity).toLocaleString()} -
- `; - - const removeBtn = cartItem.querySelector('.cart-item-remove'); - removeBtn.addEventListener('click', () => removeFromCart(item.id)); - - cartItems.appendChild(cartItem); - }); - } - - updateCartSummary(); -} - -function updateCartCount() { - const cartCount = document.querySelector('.cart-count'); - if (cartCount) { - const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0); - cartCount.textContent = totalItems; - } -} - -function updateCartSummary() { - const subtotal = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0); - const tax = subtotal * 0.05; - const total = subtotal + tax; - - document.getElementById('cartSubtotal').textContent = `$${subtotal.toLocaleString()}`; - document.getElementById('cartTax').textContent = `$${tax.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; - document.getElementById('cartTotal').textContent = `$${total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; - - const checkoutBtn = document.getElementById('checkoutBtn'); - if (checkoutBtn) { - checkoutBtn.disabled = cart.length === 0 || total > mockData.balance; - } -} - -function handleCheckout() { - const total = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0); - const tax = total * 0.05; - const grandTotal = total + tax; - - if (grandTotal > mockData.balance) { - alert('Insufficient funds!'); - return; - } - - const purchaseData = { - items: cart.map(item => ({ - id: item.id, - name: item.name, - quantity: item.quantity, - price: item.price - })), - subtotal: total, - tax: tax, - total: grandTotal - }; - - console.log('Purchase request:', purchaseData); - sendEvent('actor::store::purchase', purchaseData); - - // Clear cart after purchase - cart = []; - renderCart(); - updateCartCount(); - - // Update balance (this would normally come from server) - mockData.balance -= grandTotal; - updateBalance(); -} - -function updateBalance() { - const balanceAmount = document.querySelector('.balance-amount'); - if (balanceAmount) { - balanceAmount.textContent = `$${mockData.balance.toLocaleString()}`; - } -} - -// Update store data from external source -function updateStoreData(data) { - if (data.balance !== undefined) { - mockData.balance = data.balance; - updateBalance(); - } - - if (data.items) { - mockData.items = data.items; - renderItems(); - } -} - -// Send event to Arma -function sendEvent(event, data) { - if (typeof A3API !== 'undefined') { - A3API.SendAlert(JSON.stringify({ - event: event, - data: data - })); - } else { - console.log('Event:', event, 'Data:', data); - } -} - -// Auto-initialize -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initStore); -} else { - initStore(); -} - -// Expose functions globally -window.updateStoreData = updateStoreData; -window.addToCart = addToCart; diff --git a/arma/client/addons/actor/ui/_site/style.css b/arma/client/addons/actor/ui/_site/style.css index 6bca60b..527bc49 100644 --- a/arma/client/addons/actor/ui/_site/style.css +++ b/arma/client/addons/actor/ui/_site/style.css @@ -1,3 +1,21 @@ +:root { + --bg-app: rgba(0, 0, 0, 0.4); + --bg-surface: #ffffff; + --bg-surface-hover: #f1f5f9; + --primary: #475569; + --primary-hover: #1e293b; + --text-main: #1f2937; + --text-muted: #64748b; + --text-inverse: #f8fafc; + --border: #e2e8f0; + --radius: 8px; + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-lg: + 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --menu-radius: 160px; + --item-size: 80px; +} + * { margin: 0; padding: 0; @@ -5,112 +23,168 @@ } body { + font-family: + "Inter", + system-ui, + -apple-system, + sans-serif; height: 100vh; width: 100vw; - background: rgba(0, 0, 0, 0.5); - font-family: Arial, sans-serif; + background: var(--bg-app); + color: var(--text-main); + line-height: 1.4; + overflow: hidden; } -.container { - align-items: flex-end; +#app { + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +/* Radial Menu Container */ +.radial-menu { + position: relative; + width: calc(var(--menu-radius) * 2 + var(--item-size)); + height: calc(var(--menu-radius) * 2 + var(--item-size)); +} + +/* Center Hub */ +.radial-center { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 90px; + height: 90px; + background: var(--bg-surface); + border: 2px solid var(--border); + border-radius: 50%; display: flex; flex-direction: column; + align-items: center; justify-content: center; - height: 100%; - padding-right: 5%; - perspective: 1200px; -} + box-shadow: var(--shadow-lg); + z-index: 10; + cursor: pointer; + transition: all 0.2s ease; -.neu-menu { - background: rgba(15, 20, 30, 0.9); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - margin-right: 25%; - max-height: 640px; - width: 480px; - transform: rotateY(-10deg) translateZ(0); - transform-style: preserve-3d; - box-shadow: - -5px 0 15px rgba(100, 150, 200, 0.2), - 0 8px 32px rgba(0, 0, 0, 0.8); + &:hover { + background: var(--bg-surface-hover); + border-color: var(--primary); + transform: translate(-50%, -50%) scale(1.05); + } - .neu-menu-content { - height: 100%; - overflow: hidden; - padding: 1rem; + .center-icon { + font-size: 1.25rem; + margin-bottom: 0.15rem; + } - .neu-menu-grid { - display: grid; - max-height: 380px; - overflow-y: auto; - overflow-x: hidden; - scrollbar-width: thin; - -webkit-scrollbar-width: thin; - - .neu-menu-item { - align-items: flex-start; - background: rgba(20, 30, 45, 0.7); - border-left: 3px solid rgba(100, 150, 200, 0.5); - border-radius: 2px; - color: rgba(200, 220, 240, 0.95); - display: flex; - flex-direction: column; - justify-content: center; - margin-bottom: 0.5rem; - min-height: 70px; - padding: 0.75rem 1rem; - text-align: left; - transition: all 0.15s ease; - position: relative; - - &::before { - content: ''; - position: absolute; - left: 0; - top: 0; - height: 100%; - width: 3px; - background: rgba(100, 150, 200, 0.8); - opacity: 0; - transition: opacity 0.15s ease; - } - - &:last-child { - margin-bottom: 0 !important; - } - - &:hover { - background: rgba(30, 45, 70, 0.9); - border-left-color: rgba(150, 200, 255, 0.9); - box-shadow: - 0 0 20px rgba(100, 150, 200, 0.2), - inset 0 0 30px rgba(100, 150, 200, 0.05); - cursor: pointer; - - &::before { - opacity: 1; - } - } - - .neu-menu-item-description { - color: rgba(140, 160, 180, 0.85); - font-size: 0.8rem; - line-height: 1.3; - margin-top: 0.35rem; - } - - .neu-menu-item-icon { - display: none; - } - - .neu-menu-item-title { - color: rgba(200, 220, 255, 1); - font-size: 1rem; - font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; - } - } - } + .center-label { + font-size: 0.65rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + } +} + +/* Menu Items */ +.radial-item { + position: absolute; + width: var(--item-size); + height: var(--item-size); + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: var(--shadow); + text-align: center; + + &:hover { + background: var(--bg-surface-hover); + border-color: var(--primary); + transform: scale(1.15); + box-shadow: var(--shadow-lg); + z-index: 5; + + .radial-item-title { + color: var(--primary-hover); + } + } + + &:active { + transform: scale(0.95); + } +} + +.radial-item-icon { + font-size: 1.25rem; + margin-bottom: 0.25rem; +} + +.radial-item-title { + font-size: 0.6rem; + font-weight: 600; + color: var(--text-main); + line-height: 1.2; + transition: color 0.2s ease; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +/* Tooltip */ +.radial-tooltip { + position: fixed; + background: var(--primary-hover); + color: var(--text-inverse); + padding: 0.5rem 0.75rem; + border-radius: var(--radius); + font-size: 0.75rem; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; + z-index: 100; + box-shadow: var(--shadow-lg); + + &.visible { + opacity: 1; + } + + .tooltip-title { + font-weight: 600; + } + + .tooltip-description { + font-size: 0.65rem; + color: rgba(255, 255, 255, 0.7); + margin-top: 0.15rem; + } +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 2rem; + color: var(--text-muted); + background: var(--bg-surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + + p { + font-size: 0.9rem; } } diff --git a/arma/client/addons/actor/ui/_site/style.css.bak b/arma/client/addons/actor/ui/_site/style.css.bak deleted file mode 100644 index ad7d0ec..0000000 --- a/arma/client/addons/actor/ui/_site/style.css.bak +++ /dev/null @@ -1,186 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -:root { - --primary-color: #3b82f6; - --primary-hover: #2563eb; - --secondary-color: #1e293b; - --background-color: rgba(15, 23, 42, 0.85); - --card-background: rgba(30, 41, 59, 0.95); - --text-primary: #f8fafc; - --text-secondary: #94a3b8; - --border-color: #334155; - --success-color: #16a34a; - --success-hover: #15803d; - --button-hover: #4f46e5; -} - -body { - font-family: - "Inter", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - sans-serif; - line-height: 1.6; - background-color: transparent; - color: var(--text-primary); - height: 100vh; - display: flex; - justify-content: center; - align-items: center; -} - -.menu-container { - background-color: var(--background-color); - border-radius: 16px; - width: 90%; - max-width: 800px; - backdrop-filter: blur(10px); - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); - overflow: hidden; - - .menu-header { - background-color: var(--secondary-color); - padding: 1.5rem; - border-bottom: 1px solid var(--border-color); - margin-bottom: 20px; - - h1 { - color: var(--text-primary); - font-size: 1.5rem; - font-weight: 600; - letter-spacing: -0.025em; - margin: 0; - } - } - - .menu-content { - padding: 1.5rem; - - .menu-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 1rem; - } - } -} - -.menu-item { - background-color: var(--card-background); - border-radius: 12px; - padding: 1.25rem; - transition: all 0.3s ease; - cursor: pointer; - border: 1px solid var(--border-color); - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - animation: fadeIn 0.3s ease; - - &:hover { - transform: translateY(-4px); - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); - border-color: var(--primary-color); - } - - .menu-item-icon { - font-size: 2rem; - margin-bottom: 1rem; - background: linear-gradient( - 135deg, - var(--primary-color), - var(--button-hover) - ); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; - - svg { - width: 24px; - height: 24px; - stroke: currentColor; - fill: none; - } - } - - .menu-item-title { - font-size: 1.1rem; - font-weight: 600; - color: var(--text-primary); - margin-bottom: 0.5rem; - } - - .menu-item-description { - font-size: 0.875rem; - color: var(--text-secondary); - line-height: 1.4; - } -} - -.loading-state { - text-align: center; - padding: 40px 20px; - color: var(--text-secondary); - - .loading-spinner { - width: 40px; - height: 40px; - border: 4px solid rgba(248, 250, 252, 0.1); - border-top: 4px solid var(--primary-color); - border-radius: 50%; - animation: spin 1s linear infinite; - margin: 0 auto 20px; - } - - .loading-text { - font-size: 16px; - color: var(--text-secondary); - } -} - -@keyframes spin { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -@media (max-width: 640px) { - .menu-container { - width: 95%; - margin: 1rem; - - .menu-content { - .menu-grid { - grid-template-columns: 1fr; - } - } - - .menu-header { - h1 { - font-size: 1.25rem; - } - } - } -} diff --git a/arma/client/addons/bank/README.md b/arma/client/addons/bank/README.md index 677cfc9..a4b1503 100644 --- a/arma/client/addons/bank/README.md +++ b/arma/client/addons/bank/README.md @@ -1,4 +1,3 @@ -forge_client_bank -=================== +# forge_client_bank Description for this addon diff --git a/arma/client/addons/bank/XEH_PREP.hpp b/arma/client/addons/bank/XEH_PREP.hpp index c6ce19a..f1a55dc 100644 --- a/arma/client/addons/bank/XEH_PREP.hpp +++ b/arma/client/addons/bank/XEH_PREP.hpp @@ -1,3 +1,5 @@ PREP(handleUIEvents); -PREP(initBankClass); +PREP(initClass); +PREP(initSessionService); +PREP(initUIBridge); PREP(openUI); diff --git a/arma/client/addons/bank/XEH_postInitClient.sqf b/arma/client/addons/bank/XEH_postInitClient.sqf index ee3a2c2..a4cf8d6 100644 --- a/arma/client/addons/bank/XEH_postInitClient.sqf +++ b/arma/client/addons/bank/XEH_postInitClient.sqf @@ -1,6 +1,8 @@ #include "script_component.hpp" -if (isNil QGVAR(BankClass)) then { [] call FUNC(initBankClass); }; +if (isNil QGVAR(BankClass)) then { call FUNC(initClass); }; +if (isNil QGVAR(BankSessionService)) then { call FUNC(initSessionService); }; +if (isNil QGVAR(BankUIBridge)) then { call FUNC(initUIBridge); }; [QGVAR(initBank), { GVAR(BankClass) call ["init", []]; @@ -10,12 +12,18 @@ if (isNil QGVAR(BankClass)) then { [] call FUNC(initBankClass); }; params [["_data", createHashMap, [createHashMap]]]; GVAR(BankClass) call ["sync", [_data, true]]; + if !(isNil QGVAR(BankUIBridge)) then { + GVAR(BankUIBridge) call ["refreshSession", []]; + }; }] call CFUNC(addEventHandler); [QGVAR(responseSyncBank), { params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; GVAR(BankClass) call ["sync", [_data, _jip]]; + if !(isNil QGVAR(BankUIBridge)) then { + GVAR(BankUIBridge) call ["refreshSession", []]; + }; }] call CFUNC(addEventHandler); [{ diff --git a/arma/client/addons/bank/config.cpp b/arma/client/addons/bank/config.cpp index bce4c33..87ad980 100644 --- a/arma/client/addons/bank/config.cpp +++ b/arma/client/addons/bank/config.cpp @@ -8,6 +8,7 @@ class CfgPatches { name = COMPONENT_NAME; requiredVersion = REQUIRED_VERSION; requiredAddons[] = { + "forge_client_common", "forge_client_main" }; units[] = {}; diff --git a/arma/client/addons/bank/functions/fnc_handleUIEvents.sqf b/arma/client/addons/bank/functions/fnc_handleUIEvents.sqf index 0d0ec69..b2fcd53 100644 --- a/arma/client/addons/bank/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/bank/functions/fnc_handleUIEvents.sqf @@ -1,19 +1,25 @@ #include "..\script_component.hpp" /* + * File: fnc_handleUIEvents.sqf * Author: IDSolutions + * Date: 2025-12-16 + * Last Update: 2026-02-17 + * Public: No + * + * Description: * Handles the UI events. * * Arguments: - * None + * 0: [CONTROL] - The control that triggered the event + * 1: [BOOL] - Whether the event is from a confirm dialog + * 2: [STRING] - The message containing the event data * * Return Value: - * None + * UI events handled [BOOL] * * Example: - * [] call forge_client_bank_fnc_handleUIEvents; - * - * Public: No + * call forge_client_bank_fnc_handleUIEvents; */ params ["_control", "_isConfirmDialog", "_message"]; @@ -21,90 +27,49 @@ params ["_control", "_isConfirmDialog", "_message"]; private _alert = fromJSON _message; private _event = _alert get "event"; private _data = _alert get "data"; -private _display = displayChild findDisplay 46; - -private _uid = GVAR(BankClass) get "uid"; -private _account = GVAR(BankClass) get "account"; -private _cash = _account get "cash"; -private _bank = _account get "bank"; -private _pin = _account get "pin"; diag_log format ["[FORGE:Client:Bank] Handling UI event: %1 with data: %2", _event, _data]; switch (_event) do { - // ======================================================================== - // DATA REQUESTS - // ======================================================================== - case "bank::sync": { - private _org = 0; // TODO: Get org balance - private _players = SREG(bank,NameRegistry); - private _accountData = createHashMapFromArray [ - ["uid", _uid], - ["cash", _cash], - ["bank", _bank], - ["org", _org], - ["pin", _pin], - ["players", _players] - ]; - - _control ctrlWebBrowserAction ["ExecJS", format ["syncDataFromArma(%1)", toJSON _accountData]]; - }; - - // ======================================================================== - // BANK OPERATIONS - // ======================================================================== - case "bank::deposit": { - private _amount = _data get "amount"; - if (_amount > _cash) exitWith { hint "Insufficient cash!"; }; - - [SRPC(bank,requestDeposit), [_uid, _amount]] call CFUNC(serverEvent); - }; - case "bank::withdraw": { - private _amount = _data get "amount"; - if (_amount > _bank) exitWith { hint "Insufficient funds!"; }; - - [SRPC(bank,requestWithdraw), [_uid, _amount]] call CFUNC(serverEvent); - }; - case "bank::transfer": { - private _amount = _data get "amount"; - private _from = _data get "from"; - private _target = _data get "target"; - - // Prevent self-transfers - if (_target isEqualTo _uid) exitWith { - hint "Cannot transfer to yourself!"; - diag_log "[FORGE:Client:Bank] Attempted self-transfer blocked"; + case "bank::close": { + if !(isNil QGVAR(BankUIBridge)) then { + GVAR(BankUIBridge) call ["handleClose", []]; }; - private _fromAmount = _account get _from; - if (_amount > _fromAmount) exitWith { hint "Insufficient funds!"; }; - - [SRPC(bank,requestTransfer), [_uid, _target, _from, _amount]] call CFUNC(serverEvent); + closeDialog 1; }; - case "bank::close": { - _display closeDisplay 1; + case "bank::ready": { + if !(isNil QGVAR(BankUIBridge)) then { + GVAR(BankUIBridge) call ["handleReady", [_control, _data]]; + }; }; - - // ======================================================================== - // ATM OPERATIONS - // ======================================================================== - case "atm::withdraw": { - private _amount = _data get "amount"; - if (_amount > _bank) exitWith { hint "Insufficient funds!"; }; - - [SRPC(bank,requestWithdraw), [_uid, _amount]] call CFUNC(serverEvent); + case "bank::refresh": { + if !(isNil QGVAR(BankUIBridge)) then { + GVAR(BankUIBridge) call ["refreshSession", []]; + }; }; - case "atm::deposit": { - private _amount = _data get "amount"; - if (_amount > _cash) exitWith { hint "Insufficient cash!"; }; - - [SRPC(bank,requestDeposit), [_uid, _amount]] call CFUNC(serverEvent); + case "bank::deposit::request": { + if !(isNil QGVAR(BankUIBridge)) then { + GVAR(BankUIBridge) call ["handleDepositRequest", [_data]]; + }; }; - case "atm::close": { - _display closeDisplay 1; + case "bank::withdraw::request": { + if !(isNil QGVAR(BankUIBridge)) then { + GVAR(BankUIBridge) call ["handleWithdrawRequest", [_data]]; + }; + }; + case "bank::transfer::request": { + if !(isNil QGVAR(BankUIBridge)) then { + GVAR(BankUIBridge) call ["handleTransferRequest", [_data]]; + }; + }; + case "bank::depositEarnings::request": { + if !(isNil QGVAR(BankUIBridge)) then { + GVAR(BankUIBridge) call ["handleDepositEarningsRequest", [_data]]; + }; }; default { - diag_log format ["[FORGE:Client:Bank] Unhandled UI event: %1", _event]; + hint format ["Unhandled bank UI event: %1", _event]; }; }; diff --git a/arma/client/addons/bank/functions/fnc_initBankClass.sqf b/arma/client/addons/bank/functions/fnc_initBankClass.sqf deleted file mode 100644 index 6776935..0000000 --- a/arma/client/addons/bank/functions/fnc_initBankClass.sqf +++ /dev/null @@ -1,84 +0,0 @@ -#include "..\script_component.hpp" - -/* - * Author: IDSolutions - * Initializes the bank class. - * - * Arguments: - * None - * - * Return Value: - * None - * - * Examples: - * [] call forge_client_bank_fnc_initBankClass - * - * Public: Yes - */ - -#pragma hemtt ignore_variables ["_self"] -GVAR(BankClass) = createHashMapObject [[ - ["#type", "IBankClass"], - ["#create", { - _self set ["uid", getPlayerUID player]; - _self set ["account", createHashMap]; - _self set ["isLoaded", false]; - _self set ["lastSave", time]; - - private _account = createHashMap; - _account set ["uid", (getPlayerUID player)]; - _account set ["name", (name player)]; - _account set ["bank", 0]; - _account set ["cash", 0]; - _account set ["earnings", 0]; - _account set ["pin", 1234]; - _account set ["transactions", []]; - - _self set ["account", _account]; - }], - ["init", { - private _uid = _self get "uid"; - private _account = _self get "account"; - - [SRPC(bank,requestInitBank), [_uid, _account]] call CFUNC(serverEvent); - - systemChat format ["Bank loaded for %1", (name player)]; - diag_log "[FORGE:Client:Bank] Bank Class Initialized!"; - }], - ["save", { - params [["_sync", false, [false]]]; - - private _uid = _self get "uid"; - [SRPC(bank,requestSaveBank), [_uid, _sync]] call CFUNC(serverEvent); - - _self set ["lastSave", time]; - }], - ["sync", { - params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; - - private _account = _self get "account"; - private _isLoaded = _self get "isLoaded"; - - if (_data isEqualTo createHashMap) exitWith { - diag_log "[FORGE:Client:Bank] Empty data received for sync, skipping."; - }; - - { - _account set [_x, _y]; - } forEach _data; - - _self set ["account", _account]; - - if !(_isLoaded) then { _self set ["isLoaded", true]; }; - diag_log "[FORGE:Client:Bank] Sync completed"; - }], - ["get", { - params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; - - private _account = _self get "account"; - _account getOrDefault [_key, _default]; - }] -]]; - -SETVAR(player,FORGE_BankClass,GVAR(BankClass)); -GVAR(BankClass) diff --git a/arma/client/addons/bank/functions/fnc_initClass.sqf b/arma/client/addons/bank/functions/fnc_initClass.sqf new file mode 100644 index 0000000..ede4cc8 --- /dev/null +++ b/arma/client/addons/bank/functions/fnc_initClass.sqf @@ -0,0 +1,62 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initClass.sqf + * Author: IDSolutions + * Public: No + * + * Description: + * Initializes the bank class for account sync and access helpers. + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(BankBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "BankBaseClass"], + ["#create", compileFinal { + _self set ["uid", getPlayerUID player]; + _self set ["account", createHashMapFromArray [ + ["bank", 0], + ["cash", 0], + ["earnings", 0], + ["pin", 1234], + ["transactions", []] + ]]; + _self set ["isLoaded", false]; + _self set ["lastSave", time]; + }], + ["getAccountState", compileFinal { + _self getOrDefault ["account", createHashMap] + }], + ["get", compileFinal { + params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; + + private _account = _self getOrDefault ["account", createHashMap]; + _account getOrDefault [_key, _default] + }], + ["init", compileFinal { + [SRPC(bank,requestInitBank), [getPlayerUID player]] call CFUNC(serverEvent); + _self set ["lastSave", time]; + }], + ["save", compileFinal { + [SRPC(bank,requestSaveBank), [getPlayerUID player]] call CFUNC(serverEvent); + _self set ["lastSave", time]; + }], + ["sync", compileFinal { + params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; + + private _account = _self getOrDefault ["account", createHashMap]; + { + _account set [_x, _y]; + } forEach _data; + + _self set ["account", _account]; + if !(_self getOrDefault ["isLoaded", false]) then { + _self set ["isLoaded", true]; + }; + + true + }] +]; + +GVAR(BankClass) = createHashMapObject [GVAR(BankBaseClass)]; +GVAR(BankClass) diff --git a/arma/client/addons/bank/functions/fnc_initSessionService.sqf b/arma/client/addons/bank/functions/fnc_initSessionService.sqf new file mode 100644 index 0000000..155652b --- /dev/null +++ b/arma/client/addons/bank/functions/fnc_initSessionService.sqf @@ -0,0 +1,80 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initSessionService.sqf + * Author: IDSolutions + * Public: No + * + * Description: + * Initializes the bank session service that shapes the browser payload. + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(BankSessionServiceBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "BankSessionServiceBaseClass"], + ["buildTransferTargets", compileFinal { + private _targets = []; + + { + if (isNull _x || { _x isEqualTo player }) then { + continue; + }; + + private _uid = getPlayerUID _x; + private _name = name _x; + if (_uid isEqualTo "" || { _name isEqualTo "" }) then { + continue; + }; + + _targets pushBack (createHashMapFromArray [ + ["name", _name], + ["uid", _uid] + ]); + } forEach allPlayers; + + private _targetPairs = _targets apply { + [toLowerANSI (_x getOrDefault ["name", ""]), _x] + }; + _targetPairs sort true; + _targetPairs apply { + _x param [1, createHashMap] + } + }], + ["buildPayload", compileFinal { + params [["_mode", "bank", [""]]]; + + private _account = if (isNil QGVAR(BankClass)) then { + createHashMap + } else { + GVAR(BankClass) call ["getAccountState", []] + }; + + private _orgFunds = 0; + private _orgName = ""; + if !(isNil QEGVAR(org,OrgClass)) then { + _orgFunds = EGVAR(org,OrgClass) call ["get", ["funds", 0]]; + _orgName = EGVAR(org,OrgClass) call ["get", ["name", ""]]; + }; + + createHashMapFromArray [ + ["session", createHashMapFromArray [ + ["mode", ["bank", "atm"] select (toLowerANSI _mode isEqualTo "atm")], + ["orgFunds", _orgFunds], + ["orgName", _orgName], + ["playerName", name player], + ["transferTargets", _self call ["buildTransferTargets", []]], + ["uid", getPlayerUID player] + ]], + ["account", createHashMapFromArray [ + ["bank", _account getOrDefault ["bank", 0]], + ["cash", _account getOrDefault ["cash", 0]], + ["earnings", _account getOrDefault ["earnings", 0]], + ["pin", str (_account getOrDefault ["pin", 1234])], + ["transactions", _account getOrDefault ["transactions", []]] + ]] + ] + }] +]; + +GVAR(BankSessionService) = createHashMapObject [GVAR(BankSessionServiceBaseClass)]; +GVAR(BankSessionService) diff --git a/arma/client/addons/bank/functions/fnc_initUIBridge.sqf b/arma/client/addons/bank/functions/fnc_initUIBridge.sqf new file mode 100644 index 0000000..32e1b0b --- /dev/null +++ b/arma/client/addons/bank/functions/fnc_initUIBridge.sqf @@ -0,0 +1,134 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initUIBridge.sqf + * Author: IDSolutions + * Public: No + * + * Description: + * Initializes the bank web UI bridge. + */ + +#pragma hemtt ignore_variables ["_self"] +private _webUIDeclarations = call EFUNC(common,initWebUIBridge); +private _webUIBridgeDeclaration = _webUIDeclarations get "bridgeDeclaration"; + +GVAR(BankUIBridgeBaseClass) = compileFinal createHashMapFromArray [ + ["#base", _webUIBridgeDeclaration], + ["#type", "BankUIBridgeBaseClass"], + ["#create", compileFinal { + _self set ["mode", "bank"]; + }], + ["buildPayload", compileFinal { + GVAR(BankSessionService) call ["buildPayload", [_self call ["getMode", []]]] + }], + ["getActiveBrowserControl", compileFinal { + private _display = uiNamespace getVariable ["RscBank", displayNull]; + if (isNull _display) exitWith { + _self call ["setActiveBrowserControl", [controlNull]]; + controlNull + }; + + private _control = _display displayCtrl 1002; + _self call ["setActiveBrowserControl", [_control]]; + _control + }], + ["getMode", compileFinal { + _self getOrDefault ["mode", "bank"] + }], + ["handleDepositEarningsRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _amount = floor (_data getOrDefault ["amount", 0]); + if (_amount <= 0) exitWith { + _self call ["sendNotice", ["error", "No earnings are available to deposit."]]; + }; + + [SRPC(bank,requestDepositEarnings), [getPlayerUID player, _amount]] call CFUNC(serverEvent); + true + }], + ["handleDepositRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _amount = floor (_data getOrDefault ["amount", 0]); + if (_amount <= 0) exitWith { + _self call ["sendNotice", ["error", "Enter a valid deposit amount."]]; + }; + + [SRPC(bank,requestDeposit), [getPlayerUID player, _amount]] call CFUNC(serverEvent); + true + }], + ["handleReady", compileFinal { + params [["_control", controlNull, [controlNull]], ["_data", createHashMap, [createHashMap]]]; + + private _screen = _self call ["getScreen", []]; + _screen call ["setControl", [_control]]; + _screen call ["markReady", [true]]; + + _self call ["flushPendingEvents", []]; + _self call ["sendEvent", ["bank::hydrate", _self call ["buildPayload", []], _control]]; + }], + ["handleTransferRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _amount = floor (_data getOrDefault ["amount", 0]); + private _target = _data getOrDefault ["target", ""]; + private _from = toLowerANSI (_data getOrDefault ["from", "bank"]); + + if (_target isEqualTo "") exitWith { + _self call ["sendNotice", ["error", "Select a transfer recipient."]]; + }; + + if (_target isEqualTo getPlayerUID player) exitWith { + _self call ["sendNotice", ["error", "You cannot transfer funds to yourself."]]; + }; + + if (_amount <= 0) exitWith { + _self call ["sendNotice", ["error", "Enter a valid transfer amount."]]; + }; + + [SRPC(bank,requestTransfer), [getPlayerUID player, _target, _from, _amount]] call CFUNC(serverEvent); + true + }], + ["handleWithdrawRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _amount = floor (_data getOrDefault ["amount", 0]); + if (_amount <= 0) exitWith { + _self call ["sendNotice", ["error", "Enter a valid withdrawal amount."]]; + }; + + [SRPC(bank,requestWithdraw), [getPlayerUID player, _amount]] call CFUNC(serverEvent); + true + }], + ["refreshSession", compileFinal { + private _control = _self call ["getActiveBrowserControl", []]; + if (isNull _control) exitWith { false }; + + _self call ["sendEvent", ["bank::sync", _self call ["buildPayload", []], _control]] + }], + ["sendNotice", compileFinal { + params [["_type", "error", [""]], ["_message", "", [""]], ["_control", controlNull, [controlNull]]]; + + if (_message isEqualTo "") exitWith { false }; + + _self call ["sendEvent", ["bank::notice", createHashMapFromArray [ + ["message", _message], + ["type", _type] + ], _control]] + }], + ["setMode", compileFinal { + params [["_mode", "bank", [""]]]; + + private _finalMode = toLowerANSI _mode; + if !(_finalMode in ["bank", "atm"]) then { + _finalMode = "bank"; + }; + + _self set ["mode", _finalMode]; + _finalMode + }] +]; + +GVAR(BankUIBridge) = createHashMapObject [GVAR(BankUIBridgeBaseClass)]; +GVAR(BankUIBridge) diff --git a/arma/client/addons/bank/functions/fnc_openUI.sqf b/arma/client/addons/bank/functions/fnc_openUI.sqf index 6a65db4..9a82824 100644 --- a/arma/client/addons/bank/functions/fnc_openUI.sqf +++ b/arma/client/addons/bank/functions/fnc_openUI.sqf @@ -1,25 +1,29 @@ #include "..\script_component.hpp" /* + * File: fnc_openUI.sqf * Author: IDSolutions + * Date: 2026-01-28 + * Last Update: 2026-01-30 + * Public: No + * + * Description: * Opens the player bank interaction interface. * * Arguments: - * None + * 0: [BOOL] - Whether to open the ATM interface * * Return Value: - * None + * UI opened [BOOL] * * Example: - * [] call forge_client_bank_fnc_openUI; - * - * Public: No + * [true] call forge_client_bank_fnc_openUI; */ params [["_isATM", false, [false]]]; -private _display = (findDisplay 46) createDisplay "RscBank"; -private _ctrl = (_display displayCtrl 1002); +private _display = createDialog ["RscBank", true]; +private _ctrl = _display displayCtrl 1002; _ctrl ctrlAddEventHandler ["JSDialog", { params ["_control", "_isConfirmDialog", "_message"]; @@ -27,11 +31,11 @@ _ctrl ctrlAddEventHandler ["JSDialog", { [_control, _isConfirmDialog, _message] call FUNC(handleUIEvents); }]; -if (_isATM) then { - _ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\atm.html)]; -} else { - _ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\bank.html)]; +if !(isNil QGVAR(BankUIBridge)) then { + GVAR(BankUIBridge) call ["setMode", [["bank", "atm"] select _isATM]]; + GVAR(BankUIBridge) call ["setActiveBrowserControl", [_ctrl]]; }; -// _ctrl ctrlWebBrowserAction ["OpenDevConsole"]; + +_ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\index.html)]; true; diff --git a/arma/client/addons/bank/ui/_site/atm.css b/arma/client/addons/bank/ui/_site/atm.css deleted file mode 100644 index 4d7ba74..0000000 --- a/arma/client/addons/bank/ui/_site/atm.css +++ /dev/null @@ -1,585 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - height: 100vh; - width: 100vw; - background: rgba(0, 0, 0, 0.5); - font-family: Arial, sans-serif; - color: rgba(200, 220, 240, 0.95); - overflow: hidden; -} - -.atm-container { - height: 100vh; - width: 100vw; - display: flex; - align-items: center; - justify-content: flex-end; - padding-right: 5%; - perspective: 1200px; -} - -.atm-screen { - width: 480px; - height: 640px; - background: rgba(15, 20, 30, 0.95); - border: 2px solid rgba(100, 150, 200, 0.5); - border-radius: 8px; - transform: rotateY(-10deg) translateZ(0); - transform-style: preserve-3d; - box-shadow: - -8px 0 20px rgba(100, 150, 200, 0.25), - 0 8px 32px rgba(0, 0, 0, 0.8); - display: grid; - grid-template-rows: auto 1fr auto; - overflow: hidden; - margin-right: 25%; -} - -/* Header */ -.atm-header { - padding: 1.25rem 1.5rem; - background: rgba(20, 30, 45, 0.9); - border-bottom: 2px solid rgba(100, 150, 200, 0.3); - display: flex; - align-items: center; - gap: 1rem; -} - -.atm-logo { - font-size: 2rem; -} - -.atm-title { - font-size: 1rem; - font-weight: 600; - letter-spacing: 1px; - text-transform: uppercase; - color: rgba(100, 150, 200, 1); -} - -/* Content */ -.atm-content { - flex: 1; - padding: 1.5rem; - overflow-y: auto; - overflow-x: hidden; -} - -.atm-content::-webkit-scrollbar { - width: 6px; -} - -.atm-content::-webkit-scrollbar-track { - background: rgba(15, 20, 30, 0.5); -} - -.atm-content::-webkit-scrollbar-thumb { - background: rgba(100, 150, 200, 0.3); - border-radius: 3px; -} - -.atm-view { - display: flex; - flex-direction: column; - gap: 1.5rem; - height: 100%; - justify-content: space-between; -} - -.atm-view h3 { - font-size: 1.125rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(200, 220, 255, 1); - text-align: center; - padding-bottom: 1rem; - border-bottom: 1px solid rgba(100, 150, 200, 0.2); -} - -/* Welcome Screen */ -.welcome-message { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 1rem; - padding: 2rem 1rem; - flex: 1; -} - -.welcome-icon { - font-size: 4rem; - opacity: 0.6; -} - -.welcome-message h2 { - font-size: 1.5rem; - font-weight: 600; - color: rgba(200, 220, 255, 1); -} - -.welcome-message p { - font-size: 0.875rem; - color: rgba(140, 160, 180, 0.85); -} - -/* PIN Entry */ -.pin-entry { - display: flex; - flex-direction: column; - gap: 1.5rem; - flex: 1; - justify-content: center; -} - -.pin-entry h3 { - margin: 0; - padding: 0; - border: none; -} - -.pin-display { - display: flex; - justify-content: center; - gap: 1rem; - padding: 1.5rem; -} - -.pin-dot { - width: 16px; - height: 16px; - border-radius: 50%; - background: rgba(100, 150, 200, 0.2); - border: 2px solid rgba(100, 150, 200, 0.4); - transition: all 0.2s ease; -} - -.pin-dot.filled { - background: rgba(100, 150, 200, 0.8); - border-color: rgba(150, 200, 255, 0.8); - box-shadow: 0 0 10px rgba(100, 150, 200, 0.5); -} - -.keypad { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0.75rem; -} - -.key-btn { - padding: 1rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 1.125rem; - font-weight: 600; - cursor: pointer; - transition: all 0.15s ease; -} - -.key-btn:hover { - background: rgba(30, 45, 70, 0.9); - border-color: rgba(150, 200, 255, 0.7); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.2), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.key-btn:active { - transform: scale(0.95); -} - -.key-clear { - background: rgba(200, 150, 100, 0.2); - border-color: rgba(200, 150, 100, 0.4); -} - -.key-clear:hover { - background: rgba(200, 150, 100, 0.3); - border-color: rgba(255, 200, 150, 0.6); -} - -.key-enter { - background: rgba(100, 150, 200, 0.2); - border-color: rgba(100, 150, 200, 0.5); -} - -.key-enter:hover { - background: rgba(100, 150, 200, 0.3); - border-color: rgba(150, 200, 255, 0.7); -} - -/* Account Summary */ -.account-summary { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 1rem; - flex-shrink: 0; -} - -.summary-item { - padding: 1.25rem; - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-left: 3px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.summary-label { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(140, 160, 180, 0.85); -} - -.summary-value { - font-size: 1.25rem; - font-weight: 600; - color: rgba(100, 200, 150, 1); -} - -/* Menu Options */ -.menu-options { - display: grid; - grid-template-rows: 1fr; - gap: 1rem; -} - -.menu-btn { - padding: 1rem; - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-left: 3px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; - cursor: pointer; - transition: all 0.15s ease; -} - -.menu-btn:hover { - background: rgba(30, 45, 70, 0.8); - border-left-color: rgba(150, 200, 255, 0.7); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.15), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.menu-icon { - font-size: 2rem; -} - -.menu-text { - font-size: 0.875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(200, 220, 240, 0.95); -} - -/* Quick Amounts */ -.withdraw-display, -.deposit-display, -.transfer-display { - flex: 1; - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -.quick-amounts { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.75rem; -} - -.amount-btn { - padding: 1rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - color: rgba(100, 200, 150, 1); - font-size: 1.125rem; - font-weight: 600; - cursor: pointer; - transition: all 0.15s ease; -} - -.amount-btn:hover { - background: rgba(30, 45, 70, 0.9); - border-color: rgba(150, 200, 255, 0.6); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.15), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -/* Custom Amount */ -.custom-amount { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.custom-amount label { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(140, 160, 180, 0.85); -} - -/* Form Fields */ -.transfer-form { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.form-field { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.form-field label { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(140, 160, 180, 0.85); -} - -.amount-input, -.text-input { - padding: 0.875rem 1rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 1rem; - transition: all 0.15s ease; -} - -.amount-input:focus, -.text-input:focus { - outline: none; - border-color: rgba(150, 200, 255, 0.6); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.15), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.amount-input::placeholder, -.text-input::placeholder { - color: rgba(100, 120, 140, 0.6); -} - -/* Balance Display */ -.balance-display { - display: flex; - flex-direction: column; - gap: 1rem; - flex: 1; -} - -.balance-item { - padding: 1.25rem; - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-left: 3px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; - display: flex; - justify-content: space-between; - align-items: center; -} - -.balance-label { - font-size: 0.875rem; - color: rgba(160, 180, 200, 0.85); -} - -.balance-amount { - font-size: 1.25rem; - font-weight: 600; - color: rgba(100, 200, 150, 1); -} - -.balance-total { - border-left-color: rgba(100, 200, 150, 0.6); - background: rgba(30, 45, 70, 0.7); -} - -.balance-total .balance-label { - font-size: 1rem; - font-weight: 600; - color: rgba(200, 220, 255, 1); -} - -.balance-total .balance-amount { - font-size: 1.5rem; -} - -/* Deposit Info */ -.atm-btn-group { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.deposit-info { - padding: 1rem; - background: rgba(20, 30, 45, 0.5); - border: 1px solid rgba(100, 150, 200, 0.2); - border-radius: 4px; - text-align: center; -} - -.deposit-info p { - font-size: 0.875rem; - color: rgba(160, 180, 200, 0.85); -} - -.deposit-info span { - font-weight: 600; - color: rgba(100, 200, 150, 1); -} - -/* Transaction Result */ -.transaction-result { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 1rem; - padding: 2rem 1rem; - flex: 1; -} - -.result-icon { - width: 80px; - height: 80px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 3rem; - font-weight: bold; -} - -.transaction-result.success .result-icon { - background: rgba(100, 200, 150, 0.2); - border: 3px solid rgba(100, 200, 150, 0.6); - color: rgba(150, 255, 200, 1); - box-shadow: 0 0 20px rgba(100, 200, 150, 0.3); -} - -.transaction-result.error .result-icon { - background: rgba(200, 100, 100, 0.2); - border: 3px solid rgba(200, 100, 100, 0.6); - color: rgba(255, 150, 150, 1); - box-shadow: 0 0 20px rgba(200, 100, 100, 0.3); -} - -.transaction-result h3 { - margin: 0; - padding: 0; - border: none; -} - -.transaction-result p { - font-size: 0.875rem; - color: rgba(160, 180, 200, 0.85); - text-align: center; -} - -/* Buttons */ -.atm-btn { - padding: 1rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 0.875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - cursor: pointer; - transition: all 0.15s ease; -} - -.atm-btn:hover { - background: rgba(30, 45, 70, 0.9); - border-color: rgba(150, 200, 255, 0.7); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.2), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.atm-btn-primary { - background: rgba(100, 150, 200, 0.2); - border-color: rgba(100, 150, 200, 0.5); -} - -.atm-btn-primary:hover { - background: rgba(100, 150, 200, 0.3); - border-color: rgba(150, 200, 255, 0.7); -} - -.atm-btn-secondary { - background: rgba(200, 150, 100, 0.2); - border-color: rgba(200, 150, 100, 0.4); -} - -.atm-btn-secondary:hover { - background: rgba(200, 150, 100, 0.3); - border-color: rgba(255, 200, 150, 0.6); -} - -.atm-btn-full { - background: rgba(100, 200, 150, 0.2); - border-color: rgba(100, 200, 150, 0.4); -} - -.atm-btn-full:hover { - background: rgba(100, 200, 150, 0.3); - border-color: rgba(150, 255, 200, 0.6); -} - -/* Footer */ -.atm-footer { - padding: 1rem 1.5rem; - background: rgba(20, 30, 45, 0.9); - border-top: 2px solid rgba(100, 150, 200, 0.3); - text-align: center; -} - -.footer-text { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 1px; - color: rgba(100, 150, 200, 0.7); -} - -/* Responsive */ -@media (max-width: 768px) { - .atm-container { - justify-content: center; - padding: 1rem; - } - - .atm-screen { - transform: none; - width: 100%; - max-width: 450px; - } -} diff --git a/arma/client/addons/bank/ui/_site/atm.html b/arma/client/addons/bank/ui/_site/atm.html deleted file mode 100644 index 42b5df9..0000000 --- a/arma/client/addons/bank/ui/_site/atm.html +++ /dev/null @@ -1,248 +0,0 @@ - - - - - - - ATM - - - - - - - -
-
- -
- -
AUTOMATED TELLER
-
- - -
- -
-
-
👤
-

Welcome

-

Insert your card to begin

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
-
- - - - - diff --git a/arma/client/addons/bank/ui/_site/atm.js b/arma/client/addons/bank/ui/_site/atm.js deleted file mode 100644 index 2ec2fb6..0000000 --- a/arma/client/addons/bank/ui/_site/atm.js +++ /dev/null @@ -1,380 +0,0 @@ -/** - * ATM Interface - * Handles banking transactions with PIN authentication - */ - -// ============================================================================ -// STATE -// ============================================================================ - -let enteredPin = ''; -let currentView = 'welcomeView'; -let previousView = 'welcomeView'; -// ============================================================================ -// VIEW MANAGEMENT -// ============================================================================ - -function showView(viewId) { - // Hide all views - document.querySelectorAll('.atm-view').forEach(view => { - view.style.display = 'none'; - }); - - // Show selected view - const view = document.getElementById(viewId); - if (view) { - view.style.display = 'flex'; - previousView = currentView; - currentView = viewId; - - // Update balance displays when showing certain views - if (viewId === 'menuView' || viewId === 'balanceView' || viewId === 'depositView') { - updateBalances(); - } - } -} - -// ============================================================================ -// PIN AUTHENTICATION -// ============================================================================ - -function generateKeypad() { - const keypad = document.getElementById('keypad'); - if (!keypad) return; - - // Define keypad layout - const keys = [ - { value: '1', label: '1', type: 'number' }, - { value: '2', label: '2', type: 'number' }, - { value: '3', label: '3', type: 'number' }, - { value: '4', label: '4', type: 'number' }, - { value: '5', label: '5', type: 'number' }, - { value: '6', label: '6', type: 'number' }, - { value: '7', label: '7', type: 'number' }, - { value: '8', label: '8', type: 'number' }, - { value: '9', label: '9', type: 'number' }, - { value: 'clear', label: 'Clear', type: 'action', class: 'key-clear' }, - { value: '0', label: '0', type: 'number' }, - { value: 'enter', label: 'Enter', type: 'action', class: 'key-enter' } - ]; - - // Clear existing keypad - keypad.innerHTML = ''; - - // Generate buttons - keys.forEach(key => { - const button = document.createElement('button'); - button.className = `key-btn${key.class ? ' ' + key.class : ''}`; - button.textContent = key.label; - - // Add click handler - if (key.type === 'number') { - button.onclick = () => enterPin(key.value); - } else if (key.value === 'clear') { - button.onclick = () => clearPin(); - } else if (key.value === 'enter') { - button.onclick = () => submitPin(); - } - - keypad.appendChild(button); - }); -} - -function enterPin(digit) { - if (enteredPin.length < 4) { - enteredPin += digit; - updatePinDisplay(); - } -} - -function clearPin() { - enteredPin = ''; - updatePinDisplay(); -} - -function updatePinDisplay() { - const dots = document.querySelectorAll('.pin-dot'); - dots.forEach((dot, index) => { - if (index < enteredPin.length) { - dot.classList.add('filled'); - } else { - dot.classList.remove('filled'); - } - }); -} - -function submitPin() { - if (enteredPin.length !== 4) { - showError('Please enter a 4-digit PIN'); - return; - } - - // In a real implementation, this would validate with the server - const currentState = store.getState(); - if (enteredPin === currentState.pin) { - enteredPin = ''; - updatePinDisplay(); - showView('menuView'); - } else { - showError('Incorrect PIN'); - clearPin(); - } -} - -// ============================================================================ -// BALANCE MANAGEMENT -// ============================================================================ - -function updateBalances() { - const currentState = store.getState(); - - // Update all balance displays - const cashElements = ['cashBalance', 'cashBalanceDetail', 'availableCash']; - const bankElements = ['bankBalance', 'bankBalanceDetail']; - - cashElements.forEach(id => { - const el = document.getElementById(id); - if (el) el.textContent = `$${currentState.accounts.cash.toLocaleString()}`; - }); - - bankElements.forEach(id => { - const el = document.getElementById(id); - if (el) el.textContent = `$${currentState.accounts.bank.toLocaleString()}`; - }); - - const totalEl = document.getElementById('totalBalance'); - if (totalEl) { - const total = currentState.accounts.cash + currentState.accounts.bank; - totalEl.textContent = `$${total.toLocaleString()}`; - } -} - -// ============================================================================ -// WITHDRAW OPERATIONS -// ============================================================================ - -function withdrawAmount(amount) { - const currentState = store.getState(); - - if (amount > currentState.accounts.bank) { - showError('Insufficient funds'); - return; - } - - store.dispatch(withdraw(amount)); - sendEvent('atm::withdraw', { amount: amount }); - showSuccess(`Withdrew $${amount.toLocaleString()}`); -} - -function withdrawCustom() { - const input = document.getElementById('withdrawInput'); - const amount = parseFloat(input.value); - - if (!amount || amount <= 0) { - showError('Please enter a valid amount'); - return; - } - - const currentState = store.getState(); - if (amount > currentState.accounts.bank) { - showError('Insufficient funds'); - return; - } - - store.dispatch(withdraw(amount)); - sendEvent('atm::withdraw', { amount: amount }); - input.value = ''; - showSuccess(`Withdrew $${amount.toLocaleString()}`); -} - -// ============================================================================ -// DEPOSIT OPERATIONS -// ============================================================================ - -/** - * Deposits specified amount into bank account - * @deprecated Use store actions instead - */ -function depositAmount() { - const input = document.getElementById('depositInput'); - const amount = parseFloat(input.value); - - if (!amount || amount <= 0) { - showError('Please enter a valid amount'); - return; - } - - const currentState = store.getState(); - if (amount > currentState.accounts.cash) { - showError('Insufficient cash'); - return; - } - - store.dispatch(deposit(amount)); - sendEvent('atm::deposit', { amount: amount }); - input.value = ''; - showSuccess(`Deposited $${amount.toLocaleString()}`); -} -/** - * Deposits all available cash into bank account - * @deprecated Use store actions instead - */ -function depositAll() { - const currentState = store.getState(); - - if (currentState.accounts.cash <= 0) { - showError('No cash to deposit'); - return; - } - - const amount = currentState.accounts.cash; - store.dispatch(deposit(amount)); - sendEvent('atm::deposit', { amount: amount }); - showSuccess(`Deposited $${amount.toLocaleString()}`); -} - -// ============================================================================ -// TRANSFER OPERATIONS -// ============================================================================ -/** - * Transfers specified amount from bank account to player account - * @deprecated Use store actions instead - */ -function transferFunds() { - const playerIdInput = document.getElementById('transferPlayerId'); - const amountInput = document.getElementById('transferAmount'); - - const playerId = playerIdInput.value.trim(); - const amount = parseFloat(amountInput.value); - - if (!playerId) { - showError('Please enter a player ID'); - return; - } - - if (!amount || amount <= 0) { - showError('Please enter a valid amount'); - return; - } - - const currentState = store.getState(); - if (amount > currentState.accounts.bank) { - showError('Insufficient funds'); - return; - } - - store.dispatch(transfer('bank', amount, 'player')); - sendEvent('atm::transfer', { - playerId: playerId, - amount: amount - }); - - playerIdInput.value = ''; - amountInput.value = ''; - - showSuccess(`Transferred $${amount.toLocaleString()} to Player ${playerId}`); -} - -// ============================================================================ -// RESULT SCREENS -// ============================================================================ - -function showSuccess(message) { - document.getElementById('successMessage').textContent = message; - showView('successView'); - updateBalances(); -} - -function showError(message) { - document.getElementById('errorMessage').textContent = message; - showView('errorView'); -} - -function goBackFromError() { - // If error happened during PIN entry, go back to PIN view - // Otherwise go back to menu view - if (previousView === 'pinView') { - showView('pinView'); - } else { - showView('menuView'); - } -} - -// ============================================================================ -// ATM CONTROL -// ============================================================================ - -function exitATM() { - enteredPin = ''; - updatePinDisplay(); - sendEvent('atm::close', {}); - showView('welcomeView'); -} - -// ============================================================================ -// ARMA 3 INTEGRATION -// ============================================================================ - -/** - * Sends an event to Arma 3 - * @param {string} event - Event name - * @param {Object} data - Event data - */ -function sendEvent(event, data) { - if (typeof A3API !== 'undefined') { - A3API.SendAlert(JSON.stringify({ - event: event, - data: data - })); - } else { - console.log('Event:', event, 'Data:', data); - } -} - -// ============================================================================ -// INITIALIZATION -// ============================================================================ - -function initATM() { - // Subscribe to store updates - if (typeof store !== 'undefined') { - store.subscribe(() => { - updateBalances(); - }); - } - - // Generate keypad - generateKeypad(); - - // Show welcome screen - showView('welcomeView'); - - // Update initial balances - updateBalances(); - - console.log('[ATM] Interface initialized'); -} - -// Auto-initialize -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initATM); -} else { - initATM(); -} - -// ============================================================================ -// GLOBAL EXPORTS -// ============================================================================ - -window.showView = showView; -window.generateKeypad = generateKeypad; -window.enterPin = enterPin; -window.clearPin = clearPin; -window.submitPin = submitPin; -window.withdrawAmount = withdrawAmount; -window.withdrawCustom = withdrawCustom; -window.depositAmount = depositAmount; -window.depositAll = depositAll; -window.transferFunds = transferFunds; -window.goBackFromError = goBackFromError; -window.exitATM = exitATM; diff --git a/arma/client/addons/bank/ui/_site/bank-ui.css b/arma/client/addons/bank/ui/_site/bank-ui.css new file mode 100644 index 0000000..cea6a3b --- /dev/null +++ b/arma/client/addons/bank/ui/_site/bank-ui.css @@ -0,0 +1 @@ +:root{--bank-shell-bg:#f6f4ee;--bank-surface:linear-gradient(180deg, #fff 0%, #f4f8fd 100%);--bank-border:#12365d1f;--bank-border-strong:#12365d2e;--bank-text-main:#142f52;--bank-text-muted:#6f86a3;--bank-text-subtle:#8ea2bb;--bank-accent:#275a8c;--bank-accent-soft:#dfeaf9;--bank-accent-line:#275a8c1f;--bank-shadow:0 16px 30px #12243914}*,:before,:after{box-sizing:border-box}html,body,#app{width:100%;height:100%;margin:0}body{color:var(--bank-text-main);background:0 0;font-family:Segoe UI,Tahoma,Geneva,Verdana,sans-serif;overflow:hidden}button,input,select{font:inherit}.bank-shell{background:var(--bank-shell-bg);flex-direction:column;width:100%;height:100%;display:flex}.bank-scroll-shell{flex-direction:column;flex:1;min-height:0;display:flex;overflow:auto}.bank-layout{flex:1 0 auto;grid-template-columns:320px minmax(0,1fr);gap:1.25rem;width:min(100%,1600px);min-height:100%;margin:0 auto;padding:1.25rem;display:grid}.bank-sidebar,.bank-main{flex-direction:column;gap:1rem;min-height:0;display:flex}.bank-main{overflow:visible}.bank-module,.bank-card,.bank-atm-panel{background:var(--bank-surface);border:1px solid var(--bank-border);box-shadow:var(--bank-shadow);border-radius:1.3rem;flex-direction:column;padding:1rem;display:flex}.bank-module-header,.bank-card-header,.bank-section-header,.bank-page-header{justify-content:space-between;align-items:flex-start;gap:1rem;display:flex}.bank-module-header,.bank-card-header{margin-bottom:.9rem}.bank-page{gap:1.35rem;padding:.1rem 0 0;display:grid}.bank-page-header{padding-top:.4rem}.bank-page-copy{color:var(--bank-text-muted);max-width:48rem;margin:0;line-height:1.5}.bank-page-divider{border-top:1px solid var(--bank-accent-line)}.bank-page-body{gap:1.25rem;padding-bottom:1.25rem;display:grid}.bank-page-section{border:1px solid var(--bank-border);box-shadow:none;background:#ffffffb8;border-radius:1.3rem;gap:1rem;padding:1.15rem 1.2rem 1.25rem;display:grid}.bank-title,.bank-section-title{color:var(--bank-text-main);letter-spacing:-.02em;margin:0}.bank-title{font-size:1.7rem}.bank-section-title{font-size:1.1rem}.bank-eyebrow,.bank-footer-title,.bank-stat-label{letter-spacing:.16em;text-transform:uppercase;color:var(--bank-text-subtle);font-size:.68rem;font-weight:700;display:block}.bank-pill{background:var(--bank-accent-soft);color:var(--bank-accent);letter-spacing:.1em;text-transform:uppercase;white-space:nowrap;border-radius:999px;justify-content:center;align-items:center;padding:.48rem .8rem;font-size:.74rem;font-weight:700;display:inline-flex}.bank-summary-grid,.bank-profile-stack{gap:.8rem;display:grid}.bank-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.bank-stat-card,.bank-metric-card{border:1px solid var(--bank-border);background:#fff9;border-radius:.95rem;flex-direction:column;gap:.25rem;min-width:0;padding:.9rem;display:flex}.bank-stat-card.is-accent,.bank-metric-card.is-accent{background:linear-gradient(#edf4fe 0%,#dfeaf9 100%)}.bank-stat-card.is-success,.bank-metric-card.is-success{background:linear-gradient(#edf9f4 0%,#dff4ea 100%)}.bank-stat-card.is-warning,.bank-metric-card.is-warning{background:linear-gradient(#fdf7ea 0%,#f7edd4 100%)}.bank-stat-value,.bank-metric-value{min-width:0;color:var(--bank-text-main);overflow-wrap:anywhere;font-weight:700}.bank-stat-value{font-size:1rem}.bank-metric-value{letter-spacing:-.03em;font-size:1.8rem}.bank-metric-copy,.bank-card-copy,.bank-empty-copy,.bank-footer-copy,.bank-history-meta{color:var(--bank-text-muted);line-height:1.45}.bank-card-copy{margin:0 0 .9rem}.bank-summary-band{grid-template-columns:repeat(2,minmax(0,1fr));gap:.85rem;display:grid}.bank-action-sections{grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;display:grid}.bank-support-sections{grid-template-columns:minmax(0,1fr);gap:1rem;display:grid}.bank-form-stack{gap:.75rem;display:grid}.bank-input,.bank-select{border:1px solid var(--bank-border);width:100%;min-width:0;height:2.9rem;color:var(--bank-text-main);background:#ffffffd1;border-radius:.8rem;padding:0 .95rem}.bank-action-row{gap:.75rem;display:flex}.bank-btn{border:1px solid var(--bank-border);letter-spacing:.12em;text-transform:uppercase;cursor:pointer;border-radius:.8rem;justify-content:center;align-items:center;min-height:2.85rem;padding:.75rem 1rem;font-size:.82rem;font-weight:700;transition:background-color .16s,color .16s,border-color .16s;display:inline-flex}.bank-btn:disabled{opacity:.55;cursor:default}.bank-btn-primary{color:#fff;background:#455a77;border-color:#455a77}.bank-btn-primary:hover:not(:disabled){background:#354863;border-color:#354863}.bank-btn-secondary{color:var(--bank-accent);background:#ffffffd1}.bank-btn-secondary:hover:not(:disabled){background:#eef4fd}.bank-history-list{gap:.75rem;display:grid}.bank-history-row{border:1px solid var(--bank-border);background:#fff9;border-radius:.9rem;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem .95rem;display:flex}.bank-history-copy{gap:.18rem;min-width:0;display:grid}.bank-history-title,.bank-empty-title{color:var(--bank-text-main);font-weight:700}.bank-history-value{white-space:nowrap;color:var(--bank-accent);font-weight:700}.bank-empty-state{gap:.35rem;padding:1rem 0;display:grid}.bank-notice-stack{z-index:12;gap:.65rem;display:grid;position:fixed;top:1.2rem;right:1.5rem}.bank-notice{border:1px solid var(--bank-border);background:#fff;border-radius:.9rem;max-width:24rem;padding:.85rem 1rem;font-size:.92rem;box-shadow:0 14px 28px #10223824}.bank-notice.is-success{color:#166534;background:#ecfdf5;border-color:#bbf7d0}.bank-notice.is-error{color:#991b1b;background:#fef2f2;border-color:#fecaca}.bank-footer-bar{color:#f8fafc;background:#1e293b;width:100%;margin-top:auto}.bank-footer{grid-template-columns:repeat(2,minmax(0,1fr));gap:4rem;width:min(100%,1600px);margin:0 auto;padding:3rem 1.25rem;display:grid}.bank-footer-block{flex-direction:column;gap:.75rem;display:flex}.bank-footer-title{color:#f8fafc;text-transform:uppercase;letter-spacing:.1em;border-bottom:1px solid #475569;margin:0;padding-bottom:.5rem;font-size:.85rem;font-weight:700}.bank-footer-list{margin:0;padding:0;list-style:none}.bank-atm-shell{flex:1;justify-content:center;align-items:center;min-height:0;padding:2rem 1rem;display:flex}.bank-atm-panel{gap:1rem;width:min(100%,560px);display:grid}.bank-atm-stack{gap:1rem;display:grid}.bank-pin-display,.bank-balance-display{border:1px solid var(--bank-border-strong);min-height:5rem;color:var(--bank-text-main);text-align:center;background:#ffffffad;border-radius:1rem;justify-content:center;align-items:center;padding:1rem;display:flex}.bank-pin-display{font-size:2rem}.bank-balance-display{letter-spacing:-.03em;font-size:2.5rem;font-weight:800}.bank-pin-indicators{justify-content:center;align-items:center;gap:.9rem;display:flex}.bank-pin-indicator{border:2px solid var(--bank-accent);background:0 0;border-radius:999px;width:1rem;height:1rem}.bank-pin-indicator.is-filled{background:var(--bank-accent)}.bank-keypad{grid-template-columns:repeat(3,minmax(0,1fr));gap:.75rem;display:grid}.bank-key{border:1px solid var(--bank-border);min-height:3.2rem;color:var(--bank-text-main);background:#ffffffd1;border-radius:.9rem;padding:.9rem;font-weight:700}.bank-key.is-muted{color:var(--bank-text-muted);background:#eef2f8}.bank-key.is-accent{color:#fff;background:#455a77;border-color:#455a77}.bank-key.is-wide{grid-column:span 3}.bank-atm-action-grid{gap:.75rem;display:grid}.bank-shell.is-atm{background:0 0;justify-content:center;min-height:100%}.bank-shell.is-atm .bank-atm-shell{flex:1;width:100%;max-width:100%;min-height:100%}.bank-footer-copy{color:#cbd5e1;margin:0 0 .75rem;line-height:1.5}@media (width<=1200px){.bank-layout{grid-template-columns:1fr}.bank-main{overflow:visible}}@media (width<=900px){.bank-summary-band,.bank-action-sections,.bank-footer,.bank-summary-grid{grid-template-columns:1fr}} \ No newline at end of file diff --git a/arma/client/addons/bank/ui/_site/bank-ui.js b/arma/client/addons/bank/ui/_site/bank-ui.js new file mode 100644 index 0000000..cf05616 --- /dev/null +++ b/arma/client/addons/bank/ui/_site/bank-ui.js @@ -0,0 +1 @@ +!function(){const n=window.ForgeWebUI;(window.BankApp=window.BankApp||{}).runtime=n,window.AppRuntime=n}(),function(){const n=window.BankApp=window.BankApp||{},e={mode:"bank",orgFunds:0,orgName:"",playerName:"",transferTargets:[],uid:""},t={bank:0,cash:0,earnings:0,pin:"1234",transactions:[]};function a(n,e){var t;Object.keys(n).forEach(e=>delete n[e]),Object.assign(n,(t=e,JSON.parse(JSON.stringify(t))))}n.data={account:Object.assign({},t),session:Object.assign({},e),applyHydratePayload(n){a(this.session,Object.assign({},e,n?.session||{})),a(this.account,Object.assign({},t,n?.account||{}))}}}(),function(){const n=window.BankApp=window.BankApp||{},{createSignal:e}=n.runtime;n.store=new class{constructor(){[this.getMode,this.setMode]=e("bank"),[this.getNotice,this.setNotice]=e({text:"",type:""}),[this.getPendingAction,this.setPendingAction]=e(""),[this.getAtmView,this.setAtmView]=e("pin"),[this.getEnteredPin,this.setEnteredPin]=e(""),[this.getCustomAmount,this.setCustomAmount]=e(""),[this.getAccountVersion,this.setAccountVersion]=e(0),[this.getSessionVersion,this.setSessionVersion]=e(0)}finishAction(){this.setPendingAction("")}hydrateFromPayload(n){const e=String(n?.session?.mode||"bank").trim().toLowerCase(),t=this.getMode(),a=this.getAtmView();this.setMode("atm"===e?"atm":"bank"),this.setPendingAction(""),this.setNotice({text:"",type:""}),this.setEnteredPin(""),this.setCustomAmount(""),this.setAccountVersion(this.getAccountVersion()+1),this.setSessionVersion(this.getSessionVersion()+1),"atm"!==e?this.setAtmView("dashboard"):this.setAtmView("atm"===t?a:"pin")}resetAtm(){this.setEnteredPin(""),this.setCustomAmount(""),this.setAtmView("pin")}startAction(n){this.setPendingAction(String(n||"").trim().toLowerCase())}}}(),function(){const n=window.BankApp=window.BankApp||{},e=n.store,t=window.ForgeWebUI.createBridge({closeEvent:"bank::close",globalName:"ForgeBridge",readyEvent:"bank::ready"});function a(t){n.data.applyHydratePayload(t),e.hydrateFromPayload(t)}t.on("bank::hydrate",a),t.on("bank::sync",a),t.on("bank::notice",e=>{n.actions&&n.actions.showNotice(e.type||"error",e.message||"Bank notice received.")}),n.bridge={notifyReady:()=>t.ready({loaded:!0}),receive:t.receive,requestClose:()=>t.close({}),requestDeposit:n=>t.send("bank::deposit::request",n),requestDepositEarnings:n=>t.send("bank::depositEarnings::request",n),requestRefresh:()=>t.send("bank::refresh",{}),requestTransfer:n=>t.send("bank::transfer::request",n),requestWithdraw:n=>t.send("bank::withdraw::request",n),sendEvent:t.send}}(),function(){const n=window.BankApp=window.BankApp||{},e=n.store;let t=null;function a(){return n.data?.account||{}}function s(n){const e=Math.floor(Number(n||0));return Number.isFinite(e)?e:0}function i(n,a){e.setNotice({type:n,text:a}),t&&clearTimeout(t),t=setTimeout(()=>{e.setNotice({text:"",type:""}),t=null},3200)}function o(t){const o=s(t),r=a();if(o<=0)return i("error","Enter a valid deposit amount."),!1;if(o>Number(r.cash||0))return i("error","Cash on hand cannot cover that deposit."),!1;const c=n.bridge;if(!c||"function"!=typeof c.requestDeposit)return i("error","Deposit bridge is unavailable."),!1;e.startAction("deposit");return!!c.requestDeposit({amount:o})||(e.finishAction(),i("error","Deposit bridge is unavailable."),!1)}function r(t){const o=s(t),r=a();if(o<=0)return i("error","Enter a valid withdrawal amount."),!1;if(o>Number(r.bank||0))return i("error","Bank balance cannot cover that withdrawal."),!1;const c=n.bridge;if(!c||"function"!=typeof c.requestWithdraw)return i("error","Withdraw bridge is unavailable."),!1;e.startAction("withdraw");return!!c.requestWithdraw({amount:o})||(e.finishAction(),i("error","Withdraw bridge is unavailable."),!1)}function c(){e.setEnteredPin("")}n.actions={appendCustomAmountDigit:function(n){const t=String(n||"").trim();if(!t)return;const a=String(e.getCustomAmount()||"");a.length>=7||e.setCustomAmount(a+t)},appendPinDigit:function(n){const t=String(n||"").trim();if(!t)return;const a=String(e.getEnteredPin()||"");a.length>=4||e.setEnteredPin(a+t)},backspaceCustomAmount:function(){const n=String(e.getCustomAmount()||"");e.setCustomAmount(n.slice(0,-1))},backspacePin:function(){const n=String(e.getEnteredPin()||"");e.setEnteredPin(n.slice(0,-1))},clearCustomAmount:function(){e.setCustomAmount("")},clearPin:c,closeBank:function(){const e=n.bridge;if(e&&"function"==typeof e.requestClose){if(e.requestClose())return!0}return i("error","Bank bridge is unavailable."),!1},refreshBank:function(){const e=n.bridge;if(e&&"function"==typeof e.requestRefresh){if(e.requestRefresh())return!0}return i("error","Bank refresh bridge is unavailable."),!1},requestAtmAmount:function(n,t){const a="deposit"===String(n||"").trim().toLowerCase()?o(t):r(t);return a&&e.setAtmView("menu"),a},requestDeposit:o,requestDepositEarnings:function(t){const o=s(t),r=a();if(o<=0)return i("error","No earnings are available to deposit."),!1;if(o>Number(r.earnings||0))return i("error","Pending earnings cannot cover that deposit request."),!1;const c=n.bridge;return c&&"function"==typeof c.requestDepositEarnings?(e.startAction("depositearnings"),!!c.requestDepositEarnings({amount:o})||(e.finishAction(),i("error","Earnings bridge is unavailable."),!1)):(i("error","Earnings bridge is unavailable."),!1)},requestTransfer:function(t,o){const r=s(o),c=n.data?.session||{},u=a(),l=String(t||"").trim();if(!l)return i("error","Select a transfer recipient."),!1;if(l===String(c.uid||""))return i("error","You cannot transfer funds to yourself."),!1;if(r<=0)return i("error","Enter a valid transfer amount."),!1;if(r>Number(u.bank||0))return i("error","Bank balance cannot cover that transfer."),!1;const m=n.bridge;return m&&"function"==typeof m.requestTransfer?(e.startAction("transfer"),!!m.requestTransfer({amount:r,from:"bank",target:l})||(e.finishAction(),i("error","Transfer bridge is unavailable."),!1)):(i("error","Transfer bridge is unavailable."),!1)},requestWithdraw:r,selectAtmView:function(n){const t=String(n||"").trim();return!!t&&("pin"===t?(e.resetAtm(),!0):(e.setCustomAmount(""),e.setAtmView(t),!0))},showNotice:i,submitCustomAmount:function(n){const t=s(e.getCustomAmount()),a=String(n||"").trim().toLowerCase();if(t<=0)return i("error","Enter a valid transaction amount."),!1;const c="deposit"===a?o(t):r(t);return c&&(e.setCustomAmount(""),e.setAtmView("menu")),c},submitPin:function(){const n=String(e.getEnteredPin()||""),t=String(a().pin||"1234");return 4!==n.length?(i("error","Enter your four-digit access PIN."),!1):n!==t?(c(),i("error","Incorrect PIN."),!1):(c(),e.setAtmView("menu"),!0)}}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,{account:a}=n.data;function s(n){return`$${Math.round(Number(n||0)).toLocaleString()}`}n.componentFns=n.componentFns||{},Object.assign(n.componentFns,{clearInputValue:function(n){const e=document.getElementById(n);e&&(e.value="")},formatCurrency:s,keypad:function(n,t,a,s){return e("div",{className:"bank-keypad"},["1","2","3","4","5","6","7","8","9"].map(t=>e("button",{type:"button",className:"bank-key",onClick:()=>n(t)},t)),e("button",{type:"button",className:"bank-key is-muted",onClick:a},"C"),e("button",{type:"button",className:"bank-key",onClick:()=>n("0")},"0"),e("button",{type:"button",className:"bank-key is-accent",onClick:s},"Enter"),e("button",{type:"button",className:"bank-key is-wide",onClick:t},"Backspace"))},metricCard:function(n,t,a,s=""){return e("div",{className:s?`bank-metric-card is-${s}`:"bank-metric-card"},e("span",{className:"bank-eyebrow"},n),e("span",{className:"bank-metric-value"},t),e("span",{className:"bank-metric-copy"},a))},pending:function(n){return t.getPendingAction()===n},pinIndicators:function(n){const t=String(n||"");return e("div",{className:"bank-pin-indicators"},[0,1,2,3].map(n=>e("span",{className:ne("div",{className:"bank-history-row"},e("div",{className:"bank-history-copy"},e("span",{className:"bank-history-title"},n.type||"Transaction"),e("span",{className:"bank-history-meta"},n.date||"Pending timestamp")),e("span",{className:"bank-history-value"},s(n.amount||0)))))}})}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s,session:i}=n.data,{formatCurrency:o,statCard:r}=n.componentFns;n.componentFns=n.componentFns||{},n.componentFns.BankSidebar=function(){return t.getAccountVersion(),t.getSessionVersion(),e("aside",{className:"bank-sidebar"},e("section",{className:"bank-module"},e("div",{className:"bank-module-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Account"),e("h2",{className:"bank-section-title"},"Balances")),e("span",{className:"bank-pill"},"Live")),e("div",{className:"bank-summary-grid"},r("Bank",o(s.bank),"accent"),r("Cash",o(s.cash)),r("Earnings",o(s.earnings),s.earnings>0?"warning":""),r("Org Funds",o(i.orgFunds),i.orgFunds>0?"success":""))),e("section",{className:"bank-module"},e("div",{className:"bank-module-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Profile"),e("h2",{className:"bank-section-title"},"Account Holder")),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.refreshBank()},"Refresh")),e("div",{className:"bank-profile-stack"},r("Name",i.playerName||"Unknown"),r("UID",i.uid||"-"),r("Organization",i.orgName||"No active organization"))))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,{account:a,session:s}=n.data,{formatCurrency:i}=n.componentFns;n.componentFns=n.componentFns||{},n.componentFns.BankFooter=function(){t.getAccountVersion(),t.getSessionVersion();const n=[{title:"Banking Resources",items:["Account Access Policy","Transfer & Wire Guidelines","Cash Handling Schedule","Terminal Security Notice"]},{title:"Bank Support",items:s.orgName?[`Organization: ${s.orgName}`,`Treasury Reference: ${i(s.orgFunds)}`,`${s.transferTargets.length} transfer recipient(s) currently visible.`,`Primary Ledger: ${i(a.bank)}`]:["Organization: No active treasury link",`${s.transferTargets.length} transfer recipient(s) currently visible.`,`Primary Ledger: ${i(a.bank)}`,`Cash On Hand: ${i(a.cash)}`]}];return e("footer",{className:"bank-footer-bar"},e("div",{className:"bank-footer"},...n.map(n=>e("div",{className:"bank-footer-block"},e("h3",{className:"bank-footer-title"},n.title),e("ul",{className:"bank-footer-list"},...(n.items||[]).map(n=>e("li",{className:"bank-footer-copy"},n)))))))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s,session:i}=n.data,{clearInputValue:o,formatCurrency:r,metricCard:c,pending:u,readInputValue:l,transactionRows:m}=n.componentFns;function d(){t.getAccountVersion()}function b(){t.getSessionVersion()}n.componentFns=n.componentFns||{},n.componentFns.BankPageHeader=function(){return b(),e("div",{className:"bank-page-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Treasury Desk"),e("h1",{className:"bank-title"},"Personal Banking")),e("span",{className:"bank-pill"},i.playerName||"Account Holder"))},n.componentFns.BankSummarySection=function(){return d(),b(),e("section",{className:"bank-page-section bank-summary-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Overview"),e("h2",{className:"bank-section-title"},"Financial Position")),e("span",{className:"bank-pill"},"Banking Desk")),e("div",{className:"bank-summary-band"},c("Primary Balance",r(s.bank),"Available for transfers and withdrawals.","accent"),c("Cash On Hand",r(s.cash),"Funds currently carried by the player."),c("Pending Earnings",r(s.earnings),"Ready to sweep into the main account ledger.",s.earnings>0?"warning":""),c("Org Snapshot",r(i.orgFunds),"Reference value pulled from the organization treasury.",i.orgFunds>0?"success":"")))},n.componentFns.BankActionSections=function(){return b(),e("div",{className:"bank-action-sections"},e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Movement"),e("h2",{className:"bank-section-title"},"Deposit / Withdraw"))),e("div",{className:"bank-form-stack"},e("input",{id:"bank-amount-input",className:"bank-input",type:"number",min:"1",placeholder:"Enter amount"}),e("div",{className:"bank-action-row"},e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("deposit"),onClick:()=>{a.requestDeposit(l("bank-amount-input"))&&o("bank-amount-input")}},u("deposit")?"Depositing...":"Deposit"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",disabled:u("withdraw"),onClick:()=>{a.requestWithdraw(l("bank-amount-input"))&&o("bank-amount-input")}},u("withdraw")?"Withdrawing...":"Withdraw")))),e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Transfer"),e("h2",{className:"bank-section-title"},"Wire Funds"))),e("div",{className:"bank-form-stack"},e("select",{id:"bank-transfer-target",className:"bank-select"},e("option",{value:""},i.transferTargets.length>0?"Select recipient":"No available recipients"),i.transferTargets.map(n=>e("option",{value:n.uid},n.name||n.uid))),e("input",{id:"bank-transfer-amount",className:"bank-input",type:"number",min:"1",placeholder:"Enter transfer amount"}),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("transfer")||0===i.transferTargets.length,onClick:()=>{a.requestTransfer(l("bank-transfer-target"),l("bank-transfer-amount"))&&o("bank-transfer-amount")}},u("transfer")?"Transferring...":"Transfer Funds"))))},n.componentFns.BankSupportSection=function(){return d(),e("div",{className:"bank-support-sections"},e("section",{className:"bank-page-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"Sweep"),e("h2",{className:"bank-section-title"},"Deposit Earnings"))),e("p",{className:"bank-card-copy"},"Sweep pending earnings into the primary account when you want them reflected in the main balance."),e("button",{type:"button",className:"bank-btn bank-btn-primary",disabled:u("depositearnings")||Number(s.earnings||0)<=0,onClick:()=>a.requestDepositEarnings(s.earnings)},u("depositearnings")?"Depositing...":"Deposit Earnings")))},n.componentFns.BankHistorySection=function(){return d(),e("section",{className:"bank-page-section bank-history-section"},e("div",{className:"bank-section-header"},e("div",null,e("span",{className:"bank-eyebrow"},"History"),e("h2",{className:"bank-section-title"},"Recent Transactions"))),m())}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=n.store,a=n.actions,{account:s}=n.data,{formatCurrency:i,keypad:o,pinIndicators:r}=n.componentFns;function c(n){const t="deposit"===n?"Deposit":"Withdraw";return e("div",{className:"bank-atm-action-grid"},[20,50,100,500].map(s=>e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.requestAtmAmount(n,s)},`${t} ${i(s)}`)),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("deposit"===n?"customDeposit":"customWithdraw")},"Custom Amount"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("menu")},"Back"))}function u(n){const s="deposit"===n?"Deposit":"Withdraw";return e("div",{className:"bank-atm-stack"},e("div",{className:"bank-pin-display"},t.getCustomAmount()?i(t.getCustomAmount()):"$0"),o(a.appendCustomAmountDigit,a.backspaceCustomAmount,a.clearCustomAmount,()=>a.submitCustomAmount(n)),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("menu")},`Cancel ${s}`))}n.componentFns=n.componentFns||{},n.componentFns.ATMView=function(){t.getAccountVersion();const n=t.getAtmView(),l=String(t.getEnteredPin()||"");let m="Terminal Access",d="Authenticate with the four-digit account PIN before using the terminal.",b=null;switch(n){case"menu":m="ATM Menu",d="Select a banking action. The ATM can deposit, withdraw, and show the live account balance.",b=e("div",{className:"bank-atm-action-grid"},e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("withdraw")},"Withdraw Cash"),e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("deposit")},"Deposit Cash"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.selectAtmView("balance")},"Check Balance"),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.closeBank()},"Exit Terminal"));break;case"withdraw":m="Withdraw Cash",d="Choose a preset amount or enter a custom amount for withdrawal.",b=c("withdraw");break;case"deposit":m="Deposit Cash",d="Move cash on hand back into the main bank balance from the terminal.",b=c("deposit");break;case"customWithdraw":m="Custom Withdraw",d="Enter the exact withdrawal amount.",b=u("withdraw");break;case"customDeposit":m="Custom Deposit",d="Enter the exact deposit amount.",b=u("deposit");break;case"balance":m="Available Balance",d="Current bank balance available at this terminal.",b=e("div",{className:"bank-atm-stack"},e("div",{className:"bank-balance-display"},i(s.bank)),e("button",{type:"button",className:"bank-btn bank-btn-primary",onClick:()=>a.selectAtmView("menu")},"Return to Menu"));break;default:b=e("div",{className:"bank-atm-stack"},e("div",{className:"bank-pin-display"},r(l)),o(a.appendPinDigit,a.backspacePin,a.clearPin,a.submitPin),e("button",{type:"button",className:"bank-btn bank-btn-secondary",onClick:()=>a.closeBank()},"Exit Terminal"))}return e("div",{className:"bank-atm-shell"},e("section",{className:"bank-atm-panel"},e("div",{className:"bank-panel-header"},e("div",null,e("span",{className:"bank-eyebrow"},"ATM"),e("h1",{className:"bank-title"},m)),e("span",{className:"bank-pill"},"Secure Terminal")),e("p",{className:"bank-panel-copy"},d),b))}}(),function(){const n=window.BankApp=window.BankApp||{},{h:e}=n.runtime,t=window.SharedUI.componentFns.WindowTitleBar,a=n.store,s=n.actions;n.componentFns=n.componentFns||{},n.componentFns.NoticeLayer=function(){const n=a.getNotice();return n.text?e("div",{className:"bank-notice-stack"},e("div",{className:"error"===n.type?"bank-notice is-error":"bank-notice is-success"},n.text)):null},n.components=n.components||{},n.components.App=function(){const n=a.getMode();return e("div",{className:"atm"===n?"bank-shell is-atm":"bank-shell"},"atm"===n?null:t({kicker:"FORGE Finance",title:"Global Banking Network",onClose:()=>s.closeBank(),closeLabel:"Close banking interface"}),e("div",{id:"bank-notice-root"}),"atm"===n?e("div",{id:"bank-atm-root"}):[e("div",{className:"bank-scroll-shell","data-preserve-scroll-id":"bank-page-scroll"},[e("div",{className:"bank-layout"},e("div",{id:"bank-sidebar-root"}),e("main",{className:"bank-main"},e("div",{className:"bank-page"},e("div",{id:"bank-page-header-root"}),e("p",{className:"bank-page-copy"},"Manage deposits, withdrawals, transfers, and earnings sweeps from the same shared financial console."),e("div",{className:"bank-page-divider"}),e("div",{className:"bank-page-body"},e("div",{id:"bank-summary-section-root"}),e("div",{id:"bank-action-sections-root"}),e("div",{id:"bank-support-section-root"}),e("div",{id:"bank-history-section-root"}))))),e("div",{id:"bank-footer-root"})])])}}(),function(){const n=window.ForgeWebUI,e=window.BankApp,t=[{id:"bank-notice-root",preserveScroll:!1,render:()=>e.componentFns.NoticeLayer()},{id:"bank-sidebar-root",preserveScroll:!1,render:()=>e.componentFns.BankSidebar()},{id:"bank-page-header-root",preserveScroll:!1,render:()=>e.componentFns.BankPageHeader()},{id:"bank-summary-section-root",preserveScroll:!1,render:()=>e.componentFns.BankSummarySection()},{id:"bank-action-sections-root",preserveScroll:!1,render:()=>e.componentFns.BankActionSections()},{id:"bank-support-section-root",preserveScroll:!1,render:()=>e.componentFns.BankSupportSection()},{id:"bank-history-section-root",preserveScroll:!1,render:()=>e.componentFns.BankHistorySection()},{id:"bank-atm-root",preserveScroll:!1,render:()=>e.componentFns.ATMView()},{id:"bank-footer-root",preserveScroll:!1,render:()=>e.componentFns.BankFooter()}];n.createApp({name:"bank",root:"#app",setup({root:a}){const s=function(){const e=new Map;return{sync:function(){t.forEach(t=>{const a=document.getElementById(t.id),s=e.get(t.id);if(!a)return void(s&&(s.handle.dispose(),e.delete(t.id)));if(s&&s.container===a)return;s&&s.handle.dispose();const i=n.mount(a,t.render,{preserveScroll:t.preserveScroll});e.set(t.id,{container:a,handle:i})})}}}();n.mount(a,()=>e.components.App(),{preserveScroll:!1}),e.bridge&&e.bridge.notifyReady(),n.effect(()=>{e.store.getMode(),requestAnimationFrame(()=>{s.sync()})})}}).start()}(); \ No newline at end of file diff --git a/arma/client/addons/bank/ui/_site/bank.css b/arma/client/addons/bank/ui/_site/bank.css deleted file mode 100644 index 1c53a31..0000000 --- a/arma/client/addons/bank/ui/_site/bank.css +++ /dev/null @@ -1,449 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - height: 100vh; - width: 100vw; - background: rgba(0, 0, 0, 0.7); - font-family: Arial, sans-serif; - color: rgba(200, 220, 240, 0.95); - overflow: hidden; -} - -.bank-container { - height: 100vh; - width: 100vw; - padding: 2rem; - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.bank-header { - display: flex; - align-items: center; - gap: 1.5rem; - padding: 1.25rem 1.5rem; - background: rgba(15, 20, 30, 0.9); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - box-shadow: - 0 0 20px rgba(100, 150, 200, 0.15), - 0 4px 16px rgba(0, 0, 0, 0.8); -} - -.bank-logo { - width: 60px; - height: 60px; - background: rgba(20, 30, 45, 0.8); - border: 2px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; -} - -.logo-icon { - font-size: 2rem; -} - -.bank-info { - flex: 1; -} - -.bank-title { - font-size: 1.5rem; - font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; - color: rgba(200, 220, 255, 1); - margin-bottom: 0.25rem; -} - -.bank-subtitle { - font-size: 0.875rem; - color: rgba(140, 160, 180, 0.8); - letter-spacing: 0.5px; -} - -.header-actions { - display: flex; - gap: 0.75rem; -} - -.action-btn { - padding: 0.625rem 1.25rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.5px; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - background: rgba(30, 45, 70, 0.9); - border-color: rgba(150, 200, 255, 0.7); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.2), - inset 0 0 20px rgba(100, 150, 200, 0.05); - } - - &-primary { - background: rgba(100, 150, 200, 0.2); - border-color: rgba(100, 150, 200, 0.5); - width: 100%; - margin-top: 0.5rem; - - &:hover { - background: rgba(100, 150, 200, 0.3); - border-color: rgba(150, 200, 255, 0.7); - } - } -} - -.close-btn { - border-color: rgba(200, 100, 100, 0.4); - - &:hover { - border-color: rgba(255, 100, 100, 0.7); - box-shadow: - 0 0 15px rgba(200, 100, 100, 0.2), - inset 0 0 20px rgba(200, 100, 100, 0.05); - } -} - -.bank-content { - flex: 1; - display: grid; - grid-template-columns: 300px 1fr 350px; - gap: 1.5rem; - overflow: hidden; -} - -.bank-panel { - background: rgba(15, 20, 30, 0.9); - border: 1px solid rgba(100, 150, 200, 0.4); - border-left: 3px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; - display: flex; - flex-direction: column; - box-shadow: - 0 0 20px rgba(100, 150, 200, 0.1), - 0 4px 16px rgba(0, 0, 0, 0.6); - - &-main { - grid-column: 2; - } -} - -.panel-header { - padding: 1.25rem 1.5rem; - border-bottom: 1px solid rgba(100, 150, 200, 0.2); -} - -.panel-title { - font-size: 1rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(200, 220, 255, 1); -} - -.panel-content { - flex: 1; - padding: 1.5rem; - overflow-y: auto; - - &::-webkit-scrollbar { - width: 8px; - - &-track { - background: rgba(15, 20, 30, 0.5); - border-radius: 4px; - } - - &-thumb { - background: rgba(100, 150, 200, 0.3); - border-radius: 4px; - - &:hover { - background: rgba(100, 150, 200, 0.5); - } - } - } -} - -.account-card { - padding: 1.25rem; - margin-bottom: 1rem; - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-left: 3px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - transition: all 0.15s ease; - - &:last-child { - margin-bottom: 0; - } - - .account-header { - display: flex; - align-items: center; - gap: 1rem; - margin-bottom: 1rem; - - .account-info { - display: flex; - flex-direction: column; - gap: 0.25rem; - - .account-name { - font-size: 1rem; - font-weight: 600; - color: rgba(200, 220, 255, 1); - } - - .account-type { - font-size: 0.75rem; - color: rgba(140, 160, 180, 0.8); - } - } - } - - .account-balance { - display: flex; - justify-content: space-between; - align-items: center; - padding-top: 1rem; - border-top: 1px solid rgba(100, 150, 200, 0.2); - - .balance-label { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(140, 160, 180, 0.8); - } - - .balance-amount { - font-size: 1.25rem; - font-weight: 600; - color: rgba(100, 200, 150, 1); - } - } -} - -.action-section { - margin-bottom: 2rem; - - &:last-child { - margin-bottom: 0; - } - - .section-title { - font-size: 0.875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(180, 200, 220, 0.9); - margin-bottom: 1rem; - } - - .transfer-form { - display: flex; - flex-direction: column; - gap: 1rem; - - .form-group { - display: flex; - flex-direction: column; - gap: 0.5rem; - - .form-label { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(140, 160, 180, 0.9); - } - - .form-select, - .form-input { - padding: 0.75rem 1rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 0.875rem; - transition: all 0.15s ease; - - &:focus { - outline: none; - border-color: rgba(150, 200, 255, 0.6); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.15), - inset 0 0 20px rgba(100, 150, 200, 0.05); - } - } - - .form-select { - padding-right: 2.5rem; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%2396C8FF' d='M1 1l5 5 5-5'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 1rem center; - background-size: 12px 8px; - cursor: pointer; - } - - .form-input { - &::placeholder { - color: rgba(100, 120, 140, 0.6); - } - } - } - } -} - -input[type=number] { - -moz-appearance: textfield; - appearance: textfield; - margin: 0; - - &::-webkit-inner-spin-button, - &::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; - } -} - -.quick-actions { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 1rem; - - .quick-action-btn { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; - padding: 1.25rem; - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; - - &:hover { - background: rgba(30, 45, 70, 0.8); - border-color: rgba(150, 200, 255, 0.5); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.15), - inset 0 0 20px rgba(100, 150, 200, 0.05); - } - - .quick-action-label { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.5px; - text-align: center; - color: rgba(180, 200, 220, 0.9); - } - } -} - -.transaction-list { - display: flex; - flex-direction: column; - gap: 0.75rem; - - .transaction-item { - padding: 1rem; - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.2); - border-left: 3px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - transition: all 0.15s ease; - } - - .transaction-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; - - .transaction-type { - padding: 0.25rem 0.625rem; - border-radius: 3px; - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.5px; - font-weight: 600; - - &.deposit { - background: rgba(100, 200, 150, 0.2); - border: 1px solid rgba(100, 200, 150, 0.4); - color: rgba(150, 255, 200, 0.9); - } - - &.withdrawal { - background: rgba(200, 150, 100, 0.2); - border: 1px solid rgba(200, 150, 100, 0.4); - color: rgba(255, 200, 150, 0.9); - } - - &.transfer { - background: rgba(100, 150, 200, 0.2); - border: 1px solid rgba(100, 150, 200, 0.4); - color: rgba(150, 200, 255, 0.9); - } - } - - .transaction-amount { - font-size: 1rem; - font-weight: 600; - - &.positive { - color: rgba(100, 200, 150, 1); - } - - &.negative { - color: rgba(220, 100, 100, 1); - } - } - } - - .transaction-details { - display: flex; - justify-content: space-between; - align-items: center; - - .transaction-time { - font-size: 0.7rem; - color: rgba(100, 150, 200, 0.7); - text-transform: uppercase; - letter-spacing: 0.5px; - } - } -} - -@media (max-width: 1400px) { - .bank-content { - grid-template-columns: 280px 1fr 300px; - } -} - -@media (max-width: 1200px) { - .bank-content { - grid-template-columns: 1fr; - grid-template-rows: auto 1fr auto; - } - - .panel-main { - grid-column: 1; - } -} diff --git a/arma/client/addons/bank/ui/_site/bank.html b/arma/client/addons/bank/ui/_site/bank.html deleted file mode 100644 index c565653..0000000 --- a/arma/client/addons/bank/ui/_site/bank.html +++ /dev/null @@ -1,179 +0,0 @@ - - - - - - - Banking Services - - - - - - - -
- -
- -
-

Banking Services

-

Secure Financial Management

-
-
- -
-
- - -
- -
-
-

Your Accounts

-
-
- - - - - - - - -
-
- - -
-
-

Quick Actions

-
-
- -
-

Transfer Funds

-
-
- - -
-
- - -
- -
-
- - -
-

Quick Access

-
- - - - -
-
-
-
- - -
-
-

Recent Transactions

-
-
-
- -
-
-
-
-
- - - - - diff --git a/arma/client/addons/bank/ui/_site/bank.js b/arma/client/addons/bank/ui/_site/bank.js deleted file mode 100644 index da4db1f..0000000 --- a/arma/client/addons/bank/ui/_site/bank.js +++ /dev/null @@ -1,281 +0,0 @@ -/** - * Banking Interface - * Handles transfers, deposits, withdrawals, and account management - */ - -// ============================================================================ -// INITIALIZATION -// ============================================================================ - -function initBank() { - setupEventHandlers(); - - // Subscribe to store updates - if (typeof store !== 'undefined') { - store.subscribe(() => { - updateBalances(); - renderTransactions(); - }); - } - - // Initial render - updateBalances(); - renderTransactions(); - - console.log('[Bank] Interface initialized'); -} - -// ============================================================================ -// EVENT HANDLERS -// ============================================================================ - -function setupEventHandlers() { - // Close button - const closeBtn = document.querySelector('.close-btn'); - if (closeBtn) { - closeBtn.addEventListener('click', () => { - sendEvent('bank::close', {}); - }); - } - - // Transfer form - const transferBtn = document.getElementById('transferBtn'); - const transferFrom = document.getElementById('transferFrom'); - const amount = document.getElementById('amount'); - const playerId = document.getElementById('playerId'); - const playerIdGroup = document.getElementById('playerIdGroup'); - - // Always show player ID field since transfer is only to players - if (playerIdGroup) { - playerIdGroup.style.display = 'flex'; - } - - // Transfer button - if (transferBtn) { - transferBtn.addEventListener('click', () => { - const from = transferFrom.value; - const transferAmount = parseFloat(amount.value); - - if (!transferAmount || transferAmount <= 0) { - console.log('Please enter a valid amount'); - return; - } - - if (!playerId.value) { - console.log('Please enter a player ID'); - return; - } - - const currentState = store.getState(); - const fromAccountBalance = currentState.accounts[from]; - - if (transferAmount > fromAccountBalance) { - console.log('Insufficient funds'); - return; - } - - const transferData = { - from: from, - amount: transferAmount, - target: playerId.value - }; - - sendEvent('bank::transfer', transferData); - - // Dispatch to store to update UI - store.dispatch(transfer(from, transferAmount, 'player')); - - // Clear form - amount.value = ''; - playerId.value = ''; - }); - } - - // Quick action buttons - const quickActionBtns = document.querySelectorAll('.quick-action-btn'); - quickActionBtns.forEach(btn => { - btn.addEventListener('click', () => { - const action = btn.dataset.action; - const currentState = store.getState(); - - switch (action) { - case 'deposit-amount': - const depositAmountStr = document.getElementById('amount').value; - if (depositAmountStr && parseFloat(depositAmountStr) > 0) { - const depositAmount = parseFloat(depositAmountStr); - if (depositAmount > currentState.accounts.cash) { - console.log('Insufficient cash'); - return; - } - sendEvent('bank::deposit', { amount: depositAmount }); - store.dispatch(deposit(depositAmount)); - document.getElementById('amount').value = ''; - } else { - console.log('Please enter a valid amount'); - } - break; - case 'deposit': - const cashBalance = currentState.accounts.cash; - if (cashBalance <= 0) { - console.log('No cash to deposit'); - return; - } - sendEvent('bank::deposit', { amount: cashBalance }); - store.dispatch(deposit(cashBalance)); - break; - case 'withdraw': - const amountStr = document.getElementById('amount').value; - if (amountStr && parseFloat(amountStr) > 0) { - const withdrawAmount = parseFloat(amountStr); - sendEvent('bank::withdraw', { amount: withdrawAmount }); - store.dispatch(withdraw(withdrawAmount)); - document.getElementById('amount').value = ''; - } else { - console.log('Please enter a valid amount'); - } - break; - default: - console.log('Invalid action'); - break; - } - }); - }); -} - -// ============================================================================ -// UI UPDATES -// ============================================================================ - -function updateBalances() { - const currentState = store.getState(); - const balanceElements = document.querySelectorAll('.balance-amount'); - - // The HTML structure has 3 account cards. - // 0: Cash, 1: Bank, 2: Org - if (balanceElements.length >= 3) { - balanceElements[0].textContent = `$${currentState.accounts.cash.toLocaleString()}`; - balanceElements[1].textContent = `$${currentState.accounts.bank.toLocaleString()}`; - balanceElements[2].textContent = `$${currentState.accounts.org.toLocaleString()}`; - } - - // Update form options - const transferFrom = document.getElementById('transferFrom'); - - if (transferFrom) { - const currentSelection = transferFrom.value; - transferFrom.innerHTML = ` - - - `; - if (currentSelection && (currentSelection === 'cash' || currentSelection === 'bank')) { - transferFrom.value = currentSelection; - } - } - - // Update player list - const playerSelect = document.getElementById('playerId'); - if (playerSelect && currentState.accounts.players) { - const currentPlayerSelection = playerSelect.value; - const players = currentState.accounts.players; - const currentPlayerUid = currentState.uid; - - // Clear existing options - playerSelect.innerHTML = ''; - - // Handle hashmap structure from Arma (UID -> {name, uid}) - if (players && typeof players === 'object') { - // Convert hashmap to array and iterate - Object.keys(players).forEach(uid => { - // Skip current player to prevent self-transfers - if (uid === currentPlayerUid) { - return; - } - - const playerData = players[uid]; - if (playerData && playerData.name) { - const option = document.createElement('option'); - option.value = uid; - option.textContent = playerData.name; - playerSelect.appendChild(option); - } - }); - } - - if (currentPlayerSelection) { - // Verify if the selected player is still in the list - const optionExists = Array.from(playerSelect.options).some(opt => opt.value === currentPlayerSelection); - if (optionExists) { - playerSelect.value = currentPlayerSelection; - } - } - } -} - -function renderTransactions() { - const transactionList = document.querySelector('.transaction-list'); - if (!transactionList) return; - - transactionList.innerHTML = ''; - - const currentState = store.getState(); - - currentState.transactions.forEach((transaction, index) => { - const item = document.createElement('div'); - item.className = 'transaction-item'; - - // Deposits are gains (green), Withdrawals and Transfers are losses (red) - const isGain = transaction.type === 'Deposit'; - const amountClass = isGain ? 'positive' : 'negative'; - const displayAmount = isGain ? `+$${transaction.amount.toLocaleString()}` : `-$${Math.abs(transaction.amount).toLocaleString()}`; - - // Map transaction types to CSS classes - const typeClassMap = { - 'Deposit': 'deposit', - 'Withdraw': 'withdrawal', - 'Transfer': 'transfer' - }; - const typeClass = typeClassMap[transaction.type] || transaction.type.toLowerCase(); - - item.innerHTML = ` -
- ${transaction.type} - ${displayAmount} -
-
- ${transaction.date} -
- `; - - transactionList.appendChild(item); - }); -} - -// ============================================================================ -// ARMA 3 INTEGRATION -// ============================================================================ - -/** - * Sends an event to Arma 3 - * @param {string} event - Event name - * @param {Object} data - Event data - */ -function sendEvent(event, data) { - if (typeof A3API !== 'undefined') { - A3API.SendAlert(JSON.stringify({ - event: event, - data: data - })); - } else { - console.log('Event:', event, 'Data:', data); - } -} - -// ============================================================================ -// AUTO-INITIALIZE -// ============================================================================ - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initBank); -} else { - initBank(); -} diff --git a/arma/client/addons/bank/ui/_site/index.html b/arma/client/addons/bank/ui/_site/index.html new file mode 100644 index 0000000..e15a999 --- /dev/null +++ b/arma/client/addons/bank/ui/_site/index.html @@ -0,0 +1 @@ +FORGE Banking Console
\ No newline at end of file diff --git a/arma/client/addons/bank/ui/_site/public/fdic.png b/arma/client/addons/bank/ui/_site/public/fdic.png deleted file mode 100644 index 579e749..0000000 Binary files a/arma/client/addons/bank/ui/_site/public/fdic.png and /dev/null differ diff --git a/arma/client/addons/bank/ui/_site/public/fdic_co.paa b/arma/client/addons/bank/ui/_site/public/fdic_co.paa deleted file mode 100644 index 45fe964..0000000 Binary files a/arma/client/addons/bank/ui/_site/public/fdic_co.paa and /dev/null differ diff --git a/arma/client/addons/bank/ui/_site/public/fms.png b/arma/client/addons/bank/ui/_site/public/fms.png deleted file mode 100644 index 553b09a..0000000 Binary files a/arma/client/addons/bank/ui/_site/public/fms.png and /dev/null differ diff --git a/arma/client/addons/bank/ui/_site/public/fms_co.paa b/arma/client/addons/bank/ui/_site/public/fms_co.paa deleted file mode 100644 index 9d32a24..0000000 Binary files a/arma/client/addons/bank/ui/_site/public/fms_co.paa and /dev/null differ diff --git a/arma/client/addons/bank/ui/_site/public/gms.png b/arma/client/addons/bank/ui/_site/public/gms.png deleted file mode 100644 index a4717b2..0000000 Binary files a/arma/client/addons/bank/ui/_site/public/gms.png and /dev/null differ diff --git a/arma/client/addons/bank/ui/_site/public/gms_co.paa b/arma/client/addons/bank/ui/_site/public/gms_co.paa deleted file mode 100644 index ddc6d35..0000000 Binary files a/arma/client/addons/bank/ui/_site/public/gms_co.paa and /dev/null differ diff --git a/arma/client/addons/bank/ui/_site/store.js b/arma/client/addons/bank/ui/_site/store.js deleted file mode 100644 index ace8a45..0000000 --- a/arma/client/addons/bank/ui/_site/store.js +++ /dev/null @@ -1,270 +0,0 @@ -/** - * Banking Application Store - * Redux-like state management for bank and ATM interfaces - */ - -// ============================================================================ -// REDUX CORE IMPLEMENTATION -// ============================================================================ - -/** - * Creates a Redux-like store. - * @param {Function} reducer - A function that returns the next state tree - * @returns {Object} The store object with methods: getState, dispatch, subscribe - */ -function createStore(reducer) { - let state; - let listeners = []; - - const getState = () => state; - - const dispatch = (action) => { - state = reducer(state, action); - listeners.forEach(listener => listener()); - }; - - const subscribe = (listener) => { - listeners.push(listener); - return () => { - listeners = listeners.filter(l => l !== listener); - }; - }; - - // Initialize state - dispatch({}); - - return { getState, dispatch, subscribe }; -} - -// ============================================================================ -// STATE -// ============================================================================ - -const initialState = { - uid: '', - accounts: { - bank: 0, - cash: 0, - org: 0 - }, - pin: '1234', - transactions: [] -}; - -// ============================================================================ -// ACTION TYPES -// ============================================================================ - -const DEPOSIT = 'DEPOSIT'; -const WITHDRAW = 'WITHDRAW'; -const TRANSFER = 'TRANSFER'; -const UPDATE_ACCOUNTS = 'UPDATE_ACCOUNTS'; -const UPDATE_PIN = 'UPDATE_PIN'; - -// ============================================================================ -// ACTION CREATORS -// ============================================================================ - -const deposit = (amount) => ({ - type: DEPOSIT, - payload: amount -}); - -const withdraw = (amount) => ({ - type: WITHDRAW, - payload: amount -}); - -const transfer = (from, amount, target) => ({ - type: TRANSFER, - from: from, - payload: amount, - target: target -}); - -const updateAccounts = (accounts) => ({ - type: UPDATE_ACCOUNTS, - payload: accounts -}); - -const updatePin = (pin) => ({ - type: UPDATE_PIN, - payload: pin -}); - -// ============================================================================ -// REDUCER -// ============================================================================ - -function appReducer(state = initialState, action) { - switch (action.type) { - case DEPOSIT: - if (state.accounts.cash < action.payload) { - console.warn('Insufficient cash!'); - return state; - } - return { - ...state, - accounts: { - ...state.accounts, - bank: state.accounts.bank + action.payload, - cash: state.accounts.cash - action.payload - }, - transactions: [ - ...state.transactions, - { - type: 'Deposit', - amount: action.payload, - date: new Date().toLocaleString() - } - ] - }; - - case WITHDRAW: - if (state.accounts.bank < action.payload) { - console.warn('Insufficient funds!'); - return state; - } - return { - ...state, - accounts: { - ...state.accounts, - bank: state.accounts.bank - action.payload, - cash: state.accounts.cash + action.payload - }, - transactions: [ - ...state.transactions, - { - type: 'Withdraw', - amount: action.payload, - date: new Date().toLocaleString() - } - ] - }; - - case TRANSFER: - const fromAccount = action.from; - if (state.accounts[fromAccount] < action.payload) { - console.warn('Insufficient funds!'); - return state; - } - - const newAccounts = { ...state.accounts }; - newAccounts[fromAccount] -= action.payload; - - return { - ...state, - accounts: newAccounts, - transactions: [ - ...state.transactions, - { - type: 'Transfer', - amount: action.payload, - from: fromAccount, - target: action.target, - date: new Date().toLocaleString() - } - ] - }; - - case UPDATE_ACCOUNTS: - return { - ...state, - accounts: { - ...state.accounts, - ...action.payload - } - }; - - case UPDATE_PIN: - return { - ...state, - pin: String(action.payload) - }; - - case 'SET_UID': - return { - ...state, - uid: action.payload - }; - - default: - return state; - } -} - -// ============================================================================ -// STORE INSTANCE -// ============================================================================ - -const store = createStore(appReducer); - -// ============================================================================ -// ARMA 3 INTEGRATION -// ============================================================================ - -/** - * Sends an event to Arma 3 - * @param {string} event - Event name - * @param {Object} data - Event data - */ -function sendEvent(event, data) { - if (typeof A3API !== 'undefined') { - A3API.SendAlert(JSON.stringify({ - event: event, - data: data - })); - } else { - console.log('Event:', event, 'Data:', data); - } -} - -/** - * Syncs account data from Arma 3 into the store - * @param {Object} data - Account data from Arma 3 - */ -function syncDataFromArma(data) { - if (data && typeof data === 'object') { - const accounts = {}; - - if (data.cash !== undefined) accounts.cash = data.cash; - if (data.bank !== undefined) accounts.bank = data.bank; - if (data.org !== undefined) accounts.org = data.org; - if (data.players !== undefined) accounts.players = data.players; - - if (Object.keys(accounts).length > 0) { - store.dispatch(updateAccounts(accounts)); - } - - // Update UID if provided - if (data.uid !== undefined && data.uid !== store.getState().uid) { - store.dispatch({ type: 'SET_UID', payload: data.uid }); - } - - // Update pin if provided - if (data.pin !== undefined) { - store.dispatch(updatePin(data.pin)); - } - - console.log('[Store] Synced data from Arma:', store.getState().accounts); - } else { - console.warn('[Store] Invalid data received:', data); - } -} - -// ============================================================================ -// INITIALIZATION -// ============================================================================ - -// Request initial data from Arma on load -if (typeof A3API !== 'undefined') { - // Delay request slightly to ensure everything is loaded - setTimeout(() => { - sendEvent('bank::sync', {}); - }, 100); -} - -// Expose sync function globally for Arma to call -if (typeof window !== 'undefined') { - window.syncDataFromArma = syncDataFromArma; -} diff --git a/arma/client/addons/bank/ui/src/bootstrap.js b/arma/client/addons/bank/ui/src/bootstrap.js new file mode 100644 index 0000000..6496e13 --- /dev/null +++ b/arma/client/addons/bank/ui/src/bootstrap.js @@ -0,0 +1,116 @@ +(function () { + const ForgeWebUI = window.ForgeWebUI; + const BankApp = window.BankApp; + const islandDefinitions = [ + { + id: "bank-notice-root", + preserveScroll: false, + render: () => BankApp.componentFns.NoticeLayer(), + }, + { + id: "bank-sidebar-root", + preserveScroll: false, + render: () => BankApp.componentFns.BankSidebar(), + }, + { + id: "bank-page-header-root", + preserveScroll: false, + render: () => BankApp.componentFns.BankPageHeader(), + }, + { + id: "bank-summary-section-root", + preserveScroll: false, + render: () => BankApp.componentFns.BankSummarySection(), + }, + { + id: "bank-action-sections-root", + preserveScroll: false, + render: () => BankApp.componentFns.BankActionSections(), + }, + { + id: "bank-support-section-root", + preserveScroll: false, + render: () => BankApp.componentFns.BankSupportSection(), + }, + { + id: "bank-history-section-root", + preserveScroll: false, + render: () => BankApp.componentFns.BankHistorySection(), + }, + { + id: "bank-atm-root", + preserveScroll: false, + render: () => BankApp.componentFns.ATMView(), + }, + { + id: "bank-footer-root", + preserveScroll: false, + render: () => BankApp.componentFns.BankFooter(), + }, + ]; + + function createIslandManager() { + const mounts = new Map(); + + function sync() { + islandDefinitions.forEach((definition) => { + const container = document.getElementById(definition.id); + const current = mounts.get(definition.id); + + if (!container) { + if (current) { + current.handle.dispose(); + mounts.delete(definition.id); + } + return; + } + + if (current && current.container === container) { + return; + } + + if (current) { + current.handle.dispose(); + } + + const handle = ForgeWebUI.mount(container, definition.render, { + preserveScroll: definition.preserveScroll, + }); + mounts.set(definition.id, { + container, + handle, + }); + }); + } + + return { + sync, + }; + } + + const app = ForgeWebUI.createApp({ + name: "bank", + root: "#app", + setup({ root }) { + const islandManager = createIslandManager(); + + ForgeWebUI.mount(root, () => BankApp.components.App(), { + preserveScroll: false, + }); + + if (BankApp.bridge) { + BankApp.bridge.notifyReady(); + } + + ForgeWebUI.effect(() => { + BankApp.store.getMode(); + + requestAnimationFrame(() => { + islandManager.sync(); + }); + }); + }, + }); + + app.start(); +})(); diff --git a/arma/client/addons/bank/ui/src/bridge.js b/arma/client/addons/bank/ui/src/bridge.js new file mode 100644 index 0000000..1ceed4e --- /dev/null +++ b/arma/client/addons/bank/ui/src/bridge.js @@ -0,0 +1,51 @@ +(function () { + const BankApp = (window.BankApp = window.BankApp || {}); + const store = BankApp.store; + const bridge = window.ForgeWebUI.createBridge({ + closeEvent: "bank::close", + globalName: "ForgeBridge", + readyEvent: "bank::ready", + }); + + function hydrate(payloadData) { + BankApp.data.applyHydratePayload(payloadData); + store.hydrateFromPayload(payloadData); + } + + bridge.on("bank::hydrate", hydrate); + bridge.on("bank::sync", hydrate); + bridge.on("bank::notice", (payloadData) => { + if (BankApp.actions) { + BankApp.actions.showNotice( + payloadData.type || "error", + payloadData.message || "Bank notice received.", + ); + } + }); + + BankApp.bridge = { + notifyReady() { + return bridge.ready({ loaded: true }); + }, + receive: bridge.receive, + requestClose() { + return bridge.close({}); + }, + requestDeposit(payload) { + return bridge.send("bank::deposit::request", payload); + }, + requestDepositEarnings(payload) { + return bridge.send("bank::depositEarnings::request", payload); + }, + requestRefresh() { + return bridge.send("bank::refresh", {}); + }, + requestTransfer(payload) { + return bridge.send("bank::transfer::request", payload); + }, + requestWithdraw(payload) { + return bridge.send("bank::withdraw::request", payload); + }, + sendEvent: bridge.send, + }; +})(); diff --git a/arma/client/addons/bank/ui/src/components/AppShell.js b/arma/client/addons/bank/ui/src/components/AppShell.js new file mode 100644 index 0000000..4cb7359 --- /dev/null +++ b/arma/client/addons/bank/ui/src/components/AppShell.js @@ -0,0 +1,104 @@ +(function () { + const BankApp = (window.BankApp = window.BankApp || {}); + const { h } = BankApp.runtime; + const WindowTitleBar = window.SharedUI.componentFns.WindowTitleBar; + const store = BankApp.store; + const actions = BankApp.actions; + + BankApp.componentFns = BankApp.componentFns || {}; + BankApp.componentFns.NoticeLayer = function NoticeLayer() { + const notice = store.getNotice(); + + if (!notice.text) { + return null; + } + + return h( + "div", + { className: "bank-notice-stack" }, + h( + "div", + { + className: + notice.type === "error" + ? "bank-notice is-error" + : "bank-notice is-success", + }, + notice.text, + ), + ); + }; + + BankApp.components = BankApp.components || {}; + BankApp.components.App = function App() { + const mode = store.getMode(); + + return h( + "div", + { className: mode === "atm" ? "bank-shell is-atm" : "bank-shell" }, + mode === "atm" + ? null + : WindowTitleBar({ + kicker: "FORGE Finance", + title: "Global Banking Network", + onClose: () => actions.closeBank(), + closeLabel: "Close banking interface", + }), + h("div", { id: "bank-notice-root" }), + mode === "atm" + ? h("div", { id: "bank-atm-root" }) + : [ + h( + "div", + { + className: "bank-scroll-shell", + "data-preserve-scroll-id": "bank-page-scroll", + }, + [ + h( + "div", + { className: "bank-layout" }, + h("div", { id: "bank-sidebar-root" }), + h( + "main", + { className: "bank-main" }, + h( + "div", + { className: "bank-page" }, + h("div", { + id: "bank-page-header-root", + }), + h( + "p", + { className: "bank-page-copy" }, + "Manage deposits, withdrawals, transfers, and earnings sweeps from the same shared financial console.", + ), + h("div", { + className: "bank-page-divider", + }), + h( + "div", + { className: "bank-page-body" }, + h("div", { + id: "bank-summary-section-root", + }), + h("div", { + id: "bank-action-sections-root", + }), + h("div", { + id: "bank-support-section-root", + }), + h("div", { + id: "bank-history-section-root", + }), + ), + ), + ), + ), + h("div", { id: "bank-footer-root" }), + ], + ), + ], + ); + }; +})(); diff --git a/arma/client/addons/bank/ui/src/components/BankSidebar.js b/arma/client/addons/bank/ui/src/components/BankSidebar.js new file mode 100644 index 0000000..6199abf --- /dev/null +++ b/arma/client/addons/bank/ui/src/components/BankSidebar.js @@ -0,0 +1,91 @@ +(function () { + const BankApp = (window.BankApp = window.BankApp || {}); + const { h } = BankApp.runtime; + const store = BankApp.store; + const actions = BankApp.actions; + const { account, session } = BankApp.data; + const { formatCurrency, statCard } = BankApp.componentFns; + + BankApp.componentFns = BankApp.componentFns || {}; + BankApp.componentFns.BankSidebar = function BankSidebar() { + store.getAccountVersion(); + store.getSessionVersion(); + + return h( + "aside", + { className: "bank-sidebar" }, + h( + "section", + { className: "bank-module" }, + h( + "div", + { className: "bank-module-header" }, + h( + "div", + null, + h("span", { className: "bank-eyebrow" }, "Account"), + h( + "h2", + { className: "bank-section-title" }, + "Balances", + ), + ), + h("span", { className: "bank-pill" }, "Live"), + ), + h( + "div", + { className: "bank-summary-grid" }, + statCard("Bank", formatCurrency(account.bank), "accent"), + statCard("Cash", formatCurrency(account.cash)), + statCard( + "Earnings", + formatCurrency(account.earnings), + account.earnings > 0 ? "warning" : "", + ), + statCard( + "Org Funds", + formatCurrency(session.orgFunds), + session.orgFunds > 0 ? "success" : "", + ), + ), + ), + h( + "section", + { className: "bank-module" }, + h( + "div", + { className: "bank-module-header" }, + h( + "div", + null, + h("span", { className: "bank-eyebrow" }, "Profile"), + h( + "h2", + { className: "bank-section-title" }, + "Account Holder", + ), + ), + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-secondary", + onClick: () => actions.refreshBank(), + }, + "Refresh", + ), + ), + h( + "div", + { className: "bank-profile-stack" }, + statCard("Name", session.playerName || "Unknown"), + statCard("UID", session.uid || "-"), + statCard( + "Organization", + session.orgName || "No active organization", + ), + ), + ), + ); + }; +})(); diff --git a/arma/client/addons/bank/ui/src/components/Footer.js b/arma/client/addons/bank/ui/src/components/Footer.js new file mode 100644 index 0000000..607e333 --- /dev/null +++ b/arma/client/addons/bank/ui/src/components/Footer.js @@ -0,0 +1,72 @@ +(function () { + const BankApp = (window.BankApp = window.BankApp || {}); + const { h } = BankApp.runtime; + const store = BankApp.store; + const { account, session } = BankApp.data; + const { formatCurrency } = BankApp.componentFns; + + BankApp.componentFns = BankApp.componentFns || {}; + BankApp.componentFns.BankFooter = function BankFooter() { + store.getAccountVersion(); + store.getSessionVersion(); + + const sections = [ + { + title: "Banking Resources", + items: [ + "Account Access Policy", + "Transfer & Wire Guidelines", + "Cash Handling Schedule", + "Terminal Security Notice", + ], + }, + { + title: "Bank Support", + items: session.orgName + ? [ + `Organization: ${session.orgName}`, + `Treasury Reference: ${formatCurrency(session.orgFunds)}`, + `${session.transferTargets.length} transfer recipient(s) currently visible.`, + `Primary Ledger: ${formatCurrency(account.bank)}`, + ] + : [ + "Organization: No active treasury link", + `${session.transferTargets.length} transfer recipient(s) currently visible.`, + `Primary Ledger: ${formatCurrency(account.bank)}`, + `Cash On Hand: ${formatCurrency(account.cash)}`, + ], + }, + ]; + + return h( + "footer", + { className: "bank-footer-bar" }, + h( + "div", + { className: "bank-footer" }, + ...sections.map((section) => + h( + "div", + { className: "bank-footer-block" }, + h( + "h3", + { className: "bank-footer-title" }, + section.title, + ), + h( + "ul", + { className: "bank-footer-list" }, + ...(section.items || []).map((item) => + h( + "li", + { className: "bank-footer-copy" }, + item, + ), + ), + ), + ), + ), + ), + ); + }; +})(); diff --git a/arma/client/addons/bank/ui/src/components/common.js b/arma/client/addons/bank/ui/src/components/common.js new file mode 100644 index 0000000..4cdd707 --- /dev/null +++ b/arma/client/addons/bank/ui/src/components/common.js @@ -0,0 +1,189 @@ +(function () { + const BankApp = (window.BankApp = window.BankApp || {}); + const { h } = BankApp.runtime; + const store = BankApp.store; + const { account } = BankApp.data; + + function formatCurrency(value) { + return `$${Math.round(Number(value || 0)).toLocaleString()}`; + } + + function pending(actionName) { + return store.getPendingAction() === actionName; + } + + function statCard(label, value, tone = "") { + return h( + "div", + { + className: tone + ? `bank-stat-card is-${tone}` + : "bank-stat-card", + }, + h("span", { className: "bank-stat-label" }, label), + h("span", { className: "bank-stat-value" }, value), + ); + } + + function metricCard(label, value, copy, tone = "") { + return h( + "div", + { + className: tone + ? `bank-metric-card is-${tone}` + : "bank-metric-card", + }, + h("span", { className: "bank-eyebrow" }, label), + h("span", { className: "bank-metric-value" }, value), + h("span", { className: "bank-metric-copy" }, copy), + ); + } + + function pinIndicators(value) { + const pin = String(value || ""); + + return h( + "div", + { className: "bank-pin-indicators" }, + [0, 1, 2, 3].map((index) => + h("span", { + className: + index < pin.length + ? "bank-pin-indicator is-filled" + : "bank-pin-indicator", + }), + ), + ); + } + + function readInputValue(id) { + return document.getElementById(id)?.value || ""; + } + + function clearInputValue(id) { + const input = document.getElementById(id); + if (input) { + input.value = ""; + } + } + + function keypad(onDigit, onBackspace, onClear, onEnter) { + const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]; + + return h( + "div", + { className: "bank-keypad" }, + keys.map((digit) => + h( + "button", + { + type: "button", + className: "bank-key", + onClick: () => onDigit(digit), + }, + digit, + ), + ), + h( + "button", + { + type: "button", + className: "bank-key is-muted", + onClick: onClear, + }, + "C", + ), + h( + "button", + { + type: "button", + className: "bank-key", + onClick: () => onDigit("0"), + }, + "0", + ), + h( + "button", + { + type: "button", + className: "bank-key is-accent", + onClick: onEnter, + }, + "Enter", + ), + h( + "button", + { + type: "button", + className: "bank-key is-wide", + onClick: onBackspace, + }, + "Backspace", + ), + ); + } + + function transactionRows() { + const transactions = Array.isArray(account.transactions) + ? account.transactions + : []; + + if (transactions.length === 0) { + return h( + "div", + { className: "bank-empty-state" }, + h("h3", { className: "bank-empty-title" }, "No transactions"), + h( + "p", + { className: "bank-empty-copy" }, + "Deposits, withdrawals, and transfers will appear here after the account begins moving funds.", + ), + ); + } + + return h( + "div", + { className: "bank-history-list" }, + transactions + .slice(0, 8) + .map((entry) => + h( + "div", + { className: "bank-history-row" }, + h( + "div", + { className: "bank-history-copy" }, + h( + "span", + { className: "bank-history-title" }, + entry.type || "Transaction", + ), + h( + "span", + { className: "bank-history-meta" }, + entry.date || "Pending timestamp", + ), + ), + h( + "span", + { className: "bank-history-value" }, + formatCurrency(entry.amount || 0), + ), + ), + ), + ); + } + + BankApp.componentFns = BankApp.componentFns || {}; + Object.assign(BankApp.componentFns, { + clearInputValue, + formatCurrency, + keypad, + metricCard, + pending, + pinIndicators, + readInputValue, + statCard, + transactionRows, + }); +})(); diff --git a/arma/client/addons/bank/ui/src/data.js b/arma/client/addons/bank/ui/src/data.js new file mode 100644 index 0000000..856ca90 --- /dev/null +++ b/arma/client/addons/bank/ui/src/data.js @@ -0,0 +1,44 @@ +(function () { + const BankApp = (window.BankApp = window.BankApp || {}); + + const defaultSession = { + mode: "bank", + orgFunds: 0, + orgName: "", + playerName: "", + transferTargets: [], + uid: "", + }; + + const defaultAccount = { + bank: 0, + cash: 0, + earnings: 0, + pin: "1234", + transactions: [], + }; + + function cloneValue(value) { + return JSON.parse(JSON.stringify(value)); + } + + function replaceObject(target, source) { + Object.keys(target).forEach((key) => delete target[key]); + Object.assign(target, cloneValue(source)); + } + + BankApp.data = { + account: Object.assign({}, defaultAccount), + session: Object.assign({}, defaultSession), + applyHydratePayload(payload) { + replaceObject( + this.session, + Object.assign({}, defaultSession, payload?.session || {}), + ); + replaceObject( + this.account, + Object.assign({}, defaultAccount, payload?.account || {}), + ); + }, + }; +})(); diff --git a/arma/client/addons/bank/ui/src/pages/ATMView.js b/arma/client/addons/bank/ui/src/pages/ATMView.js new file mode 100644 index 0000000..a64984c --- /dev/null +++ b/arma/client/addons/bank/ui/src/pages/ATMView.js @@ -0,0 +1,238 @@ +(function () { + const BankApp = (window.BankApp = window.BankApp || {}); + const { h } = BankApp.runtime; + const store = BankApp.store; + const actions = BankApp.actions; + const { account } = BankApp.data; + const { formatCurrency, keypad, pinIndicators } = BankApp.componentFns; + + function atmMenuCard() { + return h( + "div", + { className: "bank-atm-action-grid" }, + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-primary", + onClick: () => actions.selectAtmView("withdraw"), + }, + "Withdraw Cash", + ), + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-primary", + onClick: () => actions.selectAtmView("deposit"), + }, + "Deposit Cash", + ), + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-secondary", + onClick: () => actions.selectAtmView("balance"), + }, + "Check Balance", + ), + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-secondary", + onClick: () => actions.closeBank(), + }, + "Exit Terminal", + ), + ); + } + + function atmAmountMenu(kind) { + const label = kind === "deposit" ? "Deposit" : "Withdraw"; + const amounts = [20, 50, 100, 500]; + + return h( + "div", + { className: "bank-atm-action-grid" }, + amounts.map((amount) => + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-primary", + onClick: () => actions.requestAtmAmount(kind, amount), + }, + `${label} ${formatCurrency(amount)}`, + ), + ), + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-secondary", + onClick: () => + actions.selectAtmView( + kind === "deposit" + ? "customDeposit" + : "customWithdraw", + ), + }, + "Custom Amount", + ), + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-secondary", + onClick: () => actions.selectAtmView("menu"), + }, + "Back", + ), + ); + } + + function atmCustomAmount(kind) { + const label = kind === "deposit" ? "Deposit" : "Withdraw"; + + return h( + "div", + { className: "bank-atm-stack" }, + h( + "div", + { className: "bank-pin-display" }, + store.getCustomAmount() + ? formatCurrency(store.getCustomAmount()) + : "$0", + ), + keypad( + actions.appendCustomAmountDigit, + actions.backspaceCustomAmount, + actions.clearCustomAmount, + () => actions.submitCustomAmount(kind), + ), + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-secondary", + onClick: () => actions.selectAtmView("menu"), + }, + `Cancel ${label}`, + ), + ); + } + + BankApp.componentFns = BankApp.componentFns || {}; + BankApp.componentFns.ATMView = function ATMView() { + store.getAccountVersion(); + const atmViewName = store.getAtmView(); + const enteredPin = String(store.getEnteredPin() || ""); + let title = "Terminal Access"; + let copy = + "Authenticate with the four-digit account PIN before using the terminal."; + let content = null; + + switch (atmViewName) { + case "menu": + title = "ATM Menu"; + copy = + "Select a banking action. The ATM can deposit, withdraw, and show the live account balance."; + content = atmMenuCard(); + break; + case "withdraw": + title = "Withdraw Cash"; + copy = + "Choose a preset amount or enter a custom amount for withdrawal."; + content = atmAmountMenu("withdraw"); + break; + case "deposit": + title = "Deposit Cash"; + copy = + "Move cash on hand back into the main bank balance from the terminal."; + content = atmAmountMenu("deposit"); + break; + case "customWithdraw": + title = "Custom Withdraw"; + copy = "Enter the exact withdrawal amount."; + content = atmCustomAmount("withdraw"); + break; + case "customDeposit": + title = "Custom Deposit"; + copy = "Enter the exact deposit amount."; + content = atmCustomAmount("deposit"); + break; + case "balance": + title = "Available Balance"; + copy = "Current bank balance available at this terminal."; + content = h( + "div", + { className: "bank-atm-stack" }, + h( + "div", + { className: "bank-balance-display" }, + formatCurrency(account.bank), + ), + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-primary", + onClick: () => actions.selectAtmView("menu"), + }, + "Return to Menu", + ), + ); + break; + default: + content = h( + "div", + { className: "bank-atm-stack" }, + h( + "div", + { className: "bank-pin-display" }, + pinIndicators(enteredPin), + ), + keypad( + actions.appendPinDigit, + actions.backspacePin, + actions.clearPin, + actions.submitPin, + ), + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-secondary", + onClick: () => actions.closeBank(), + }, + "Exit Terminal", + ), + ); + break; + } + + return h( + "div", + { className: "bank-atm-shell" }, + h( + "section", + { className: "bank-atm-panel" }, + h( + "div", + { className: "bank-panel-header" }, + h( + "div", + null, + h("span", { className: "bank-eyebrow" }, "ATM"), + h("h1", { className: "bank-title" }, title), + ), + h("span", { className: "bank-pill" }, "Secure Terminal"), + ), + h("p", { className: "bank-panel-copy" }, copy), + content, + ), + ); + }; +})(); diff --git a/arma/client/addons/bank/ui/src/pages/BankView.js b/arma/client/addons/bank/ui/src/pages/BankView.js new file mode 100644 index 0000000..e3f8f0a --- /dev/null +++ b/arma/client/addons/bank/ui/src/pages/BankView.js @@ -0,0 +1,321 @@ +(function () { + const BankApp = (window.BankApp = window.BankApp || {}); + const { h } = BankApp.runtime; + const store = BankApp.store; + const actions = BankApp.actions; + const { account, session } = BankApp.data; + const { + clearInputValue, + formatCurrency, + metricCard, + pending, + readInputValue, + transactionRows, + } = BankApp.componentFns; + + function trackAccount() { + store.getAccountVersion(); + } + + function trackSession() { + store.getSessionVersion(); + } + + function pageHeader() { + trackSession(); + + return h( + "div", + { className: "bank-page-header" }, + h( + "div", + null, + h("span", { className: "bank-eyebrow" }, "Treasury Desk"), + h("h1", { className: "bank-title" }, "Personal Banking"), + ), + h( + "span", + { className: "bank-pill" }, + session.playerName || "Account Holder", + ), + ); + } + + function summarySection() { + trackAccount(); + trackSession(); + + return h( + "section", + { className: "bank-page-section bank-summary-section" }, + h( + "div", + { className: "bank-section-header" }, + h( + "div", + null, + h("span", { className: "bank-eyebrow" }, "Overview"), + h( + "h2", + { className: "bank-section-title" }, + "Financial Position", + ), + ), + h("span", { className: "bank-pill" }, "Banking Desk"), + ), + h( + "div", + { className: "bank-summary-band" }, + metricCard( + "Primary Balance", + formatCurrency(account.bank), + "Available for transfers and withdrawals.", + "accent", + ), + metricCard( + "Cash On Hand", + formatCurrency(account.cash), + "Funds currently carried by the player.", + ), + metricCard( + "Pending Earnings", + formatCurrency(account.earnings), + "Ready to sweep into the main account ledger.", + account.earnings > 0 ? "warning" : "", + ), + metricCard( + "Org Snapshot", + formatCurrency(session.orgFunds), + "Reference value pulled from the organization treasury.", + session.orgFunds > 0 ? "success" : "", + ), + ), + ); + } + + function actionSections() { + trackSession(); + + return h( + "div", + { className: "bank-action-sections" }, + h( + "section", + { className: "bank-page-section" }, + h( + "div", + { className: "bank-section-header" }, + h( + "div", + null, + h("span", { className: "bank-eyebrow" }, "Movement"), + h( + "h2", + { className: "bank-section-title" }, + "Deposit / Withdraw", + ), + ), + ), + h( + "div", + { className: "bank-form-stack" }, + h("input", { + id: "bank-amount-input", + className: "bank-input", + type: "number", + min: "1", + placeholder: "Enter amount", + }), + h( + "div", + { className: "bank-action-row" }, + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-primary", + disabled: pending("deposit"), + onClick: () => { + const sent = actions.requestDeposit( + readInputValue("bank-amount-input"), + ); + if (sent) { + clearInputValue("bank-amount-input"); + } + }, + }, + pending("deposit") ? "Depositing..." : "Deposit", + ), + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-secondary", + disabled: pending("withdraw"), + onClick: () => { + const sent = actions.requestWithdraw( + readInputValue("bank-amount-input"), + ); + if (sent) { + clearInputValue("bank-amount-input"); + } + }, + }, + pending("withdraw") ? "Withdrawing..." : "Withdraw", + ), + ), + ), + ), + h( + "section", + { className: "bank-page-section" }, + h( + "div", + { className: "bank-section-header" }, + h( + "div", + null, + h("span", { className: "bank-eyebrow" }, "Transfer"), + h( + "h2", + { className: "bank-section-title" }, + "Wire Funds", + ), + ), + ), + h( + "div", + { className: "bank-form-stack" }, + h( + "select", + { + id: "bank-transfer-target", + className: "bank-select", + }, + h( + "option", + { value: "" }, + session.transferTargets.length > 0 + ? "Select recipient" + : "No available recipients", + ), + session.transferTargets.map((entry) => + h( + "option", + { value: entry.uid }, + entry.name || entry.uid, + ), + ), + ), + h("input", { + id: "bank-transfer-amount", + className: "bank-input", + type: "number", + min: "1", + placeholder: "Enter transfer amount", + }), + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-primary", + disabled: + pending("transfer") || + session.transferTargets.length === 0, + onClick: () => { + const sent = actions.requestTransfer( + readInputValue("bank-transfer-target"), + readInputValue("bank-transfer-amount"), + ); + if (sent) { + clearInputValue("bank-transfer-amount"); + } + }, + }, + pending("transfer") + ? "Transferring..." + : "Transfer Funds", + ), + ), + ), + ); + } + + function supportSection() { + trackAccount(); + + return h( + "div", + { className: "bank-support-sections" }, + h( + "section", + { className: "bank-page-section" }, + h( + "div", + { className: "bank-section-header" }, + h( + "div", + null, + h("span", { className: "bank-eyebrow" }, "Sweep"), + h( + "h2", + { className: "bank-section-title" }, + "Deposit Earnings", + ), + ), + ), + h( + "p", + { className: "bank-card-copy" }, + "Sweep pending earnings into the primary account when you want them reflected in the main balance.", + ), + h( + "button", + { + type: "button", + className: "bank-btn bank-btn-primary", + disabled: + pending("depositearnings") || + Number(account.earnings || 0) <= 0, + onClick: () => + actions.requestDepositEarnings(account.earnings), + }, + pending("depositearnings") + ? "Depositing..." + : "Deposit Earnings", + ), + ), + ); + } + + function historySection() { + trackAccount(); + + return h( + "section", + { className: "bank-page-section bank-history-section" }, + h( + "div", + { className: "bank-section-header" }, + h( + "div", + null, + h("span", { className: "bank-eyebrow" }, "History"), + h( + "h2", + { className: "bank-section-title" }, + "Recent Transactions", + ), + ), + ), + transactionRows(), + ); + } + + BankApp.componentFns = BankApp.componentFns || {}; + BankApp.componentFns.BankPageHeader = pageHeader; + BankApp.componentFns.BankSummarySection = summarySection; + BankApp.componentFns.BankActionSections = actionSections; + BankApp.componentFns.BankSupportSection = supportSection; + BankApp.componentFns.BankHistorySection = historySection; +})(); diff --git a/arma/client/addons/bank/ui/src/registry/events.js b/arma/client/addons/bank/ui/src/registry/events.js new file mode 100644 index 0000000..01facaa --- /dev/null +++ b/arma/client/addons/bank/ui/src/registry/events.js @@ -0,0 +1,343 @@ +(function () { + const BankApp = (window.BankApp = window.BankApp || {}); + const store = BankApp.store; + + let noticeTimer = null; + + function getAccount() { + return BankApp.data?.account || {}; + } + + function getSession() { + return BankApp.data?.session || {}; + } + + function normalizeAmount(value) { + const amount = Math.floor(Number(value || 0)); + return Number.isFinite(amount) ? amount : 0; + } + + function showNotice(type, text) { + store.setNotice({ type, text }); + + if (noticeTimer) { + clearTimeout(noticeTimer); + } + + noticeTimer = setTimeout(() => { + store.setNotice({ text: "", type: "" }); + noticeTimer = null; + }, 3200); + } + + function closeBank() { + const bridge = BankApp.bridge; + if (bridge && typeof bridge.requestClose === "function") { + const sent = bridge.requestClose(); + if (sent) { + return true; + } + } + + showNotice("error", "Bank bridge is unavailable."); + return false; + } + + function refreshBank() { + const bridge = BankApp.bridge; + if (bridge && typeof bridge.requestRefresh === "function") { + const sent = bridge.requestRefresh(); + if (sent) { + return true; + } + } + + showNotice("error", "Bank refresh bridge is unavailable."); + return false; + } + + function requestDeposit(amountValue) { + const amount = normalizeAmount(amountValue); + const account = getAccount(); + + if (amount <= 0) { + showNotice("error", "Enter a valid deposit amount."); + return false; + } + + if (amount > Number(account.cash || 0)) { + showNotice("error", "Cash on hand cannot cover that deposit."); + return false; + } + + const bridge = BankApp.bridge; + if (!bridge || typeof bridge.requestDeposit !== "function") { + showNotice("error", "Deposit bridge is unavailable."); + return false; + } + + store.startAction("deposit"); + const sent = bridge.requestDeposit({ amount }); + if (!sent) { + store.finishAction(); + showNotice("error", "Deposit bridge is unavailable."); + return false; + } + + return true; + } + + function requestWithdraw(amountValue) { + const amount = normalizeAmount(amountValue); + const account = getAccount(); + + if (amount <= 0) { + showNotice("error", "Enter a valid withdrawal amount."); + return false; + } + + if (amount > Number(account.bank || 0)) { + showNotice("error", "Bank balance cannot cover that withdrawal."); + return false; + } + + const bridge = BankApp.bridge; + if (!bridge || typeof bridge.requestWithdraw !== "function") { + showNotice("error", "Withdraw bridge is unavailable."); + return false; + } + + store.startAction("withdraw"); + const sent = bridge.requestWithdraw({ amount }); + if (!sent) { + store.finishAction(); + showNotice("error", "Withdraw bridge is unavailable."); + return false; + } + + return true; + } + + function requestTransfer(targetUid, amountValue) { + const amount = normalizeAmount(amountValue); + const session = getSession(); + const account = getAccount(); + const targetId = String(targetUid || "").trim(); + + if (!targetId) { + showNotice("error", "Select a transfer recipient."); + return false; + } + + if (targetId === String(session.uid || "")) { + showNotice("error", "You cannot transfer funds to yourself."); + return false; + } + + if (amount <= 0) { + showNotice("error", "Enter a valid transfer amount."); + return false; + } + + if (amount > Number(account.bank || 0)) { + showNotice("error", "Bank balance cannot cover that transfer."); + return false; + } + + const bridge = BankApp.bridge; + if (!bridge || typeof bridge.requestTransfer !== "function") { + showNotice("error", "Transfer bridge is unavailable."); + return false; + } + + store.startAction("transfer"); + const sent = bridge.requestTransfer({ + amount, + from: "bank", + target: targetId, + }); + if (!sent) { + store.finishAction(); + showNotice("error", "Transfer bridge is unavailable."); + return false; + } + + return true; + } + + function requestDepositEarnings(amountValue) { + const amount = normalizeAmount(amountValue); + const account = getAccount(); + + if (amount <= 0) { + showNotice("error", "No earnings are available to deposit."); + return false; + } + + if (amount > Number(account.earnings || 0)) { + showNotice( + "error", + "Pending earnings cannot cover that deposit request.", + ); + return false; + } + + const bridge = BankApp.bridge; + if (!bridge || typeof bridge.requestDepositEarnings !== "function") { + showNotice("error", "Earnings bridge is unavailable."); + return false; + } + + store.startAction("depositearnings"); + const sent = bridge.requestDepositEarnings({ amount }); + if (!sent) { + store.finishAction(); + showNotice("error", "Earnings bridge is unavailable."); + return false; + } + + return true; + } + + function appendPinDigit(digit) { + const nextDigit = String(digit || "").trim(); + if (!nextDigit) { + return; + } + + const currentPin = String(store.getEnteredPin() || ""); + if (currentPin.length >= 4) { + return; + } + + store.setEnteredPin(currentPin + nextDigit); + } + + function backspacePin() { + const currentPin = String(store.getEnteredPin() || ""); + store.setEnteredPin(currentPin.slice(0, -1)); + } + + function clearPin() { + store.setEnteredPin(""); + } + + function submitPin() { + const enteredPin = String(store.getEnteredPin() || ""); + const actualPin = String(getAccount().pin || "1234"); + + if (enteredPin.length !== 4) { + showNotice("error", "Enter your four-digit access PIN."); + return false; + } + + if (enteredPin !== actualPin) { + clearPin(); + showNotice("error", "Incorrect PIN."); + return false; + } + + clearPin(); + store.setAtmView("menu"); + return true; + } + + function selectAtmView(view) { + const nextView = String(view || "").trim(); + if (!nextView) { + return false; + } + + if (nextView === "pin") { + store.resetAtm(); + return true; + } + + store.setCustomAmount(""); + store.setAtmView(nextView); + return true; + } + + function appendCustomAmountDigit(digit) { + const nextDigit = String(digit || "").trim(); + if (!nextDigit) { + return; + } + + const currentValue = String(store.getCustomAmount() || ""); + if (currentValue.length >= 7) { + return; + } + + store.setCustomAmount(currentValue + nextDigit); + } + + function backspaceCustomAmount() { + const currentValue = String(store.getCustomAmount() || ""); + store.setCustomAmount(currentValue.slice(0, -1)); + } + + function clearCustomAmount() { + store.setCustomAmount(""); + } + + function submitCustomAmount(kind) { + const amount = normalizeAmount(store.getCustomAmount()); + const nextKind = String(kind || "") + .trim() + .toLowerCase(); + + if (amount <= 0) { + showNotice("error", "Enter a valid transaction amount."); + return false; + } + + const success = + nextKind === "deposit" + ? requestDeposit(amount) + : requestWithdraw(amount); + + if (success) { + store.setCustomAmount(""); + store.setAtmView("menu"); + } + + return success; + } + + function requestAtmAmount(kind, amount) { + const nextKind = String(kind || "") + .trim() + .toLowerCase(); + const success = + nextKind === "deposit" + ? requestDeposit(amount) + : requestWithdraw(amount); + + if (success) { + store.setAtmView("menu"); + } + + return success; + } + + BankApp.actions = { + appendCustomAmountDigit, + appendPinDigit, + backspaceCustomAmount, + backspacePin, + clearCustomAmount, + clearPin, + closeBank, + refreshBank, + requestAtmAmount, + requestDeposit, + requestDepositEarnings, + requestTransfer, + requestWithdraw, + selectAtmView, + showNotice, + submitCustomAmount, + submitPin, + }; +})(); diff --git a/arma/client/addons/bank/ui/src/registry/store.js b/arma/client/addons/bank/ui/src/registry/store.js new file mode 100644 index 0000000..56b7233 --- /dev/null +++ b/arma/client/addons/bank/ui/src/registry/store.js @@ -0,0 +1,63 @@ +(function () { + const BankApp = (window.BankApp = window.BankApp || {}); + const { createSignal } = BankApp.runtime; + + class BankStore { + constructor() { + [this.getMode, this.setMode] = createSignal("bank"); + [this.getNotice, this.setNotice] = createSignal({ + text: "", + type: "", + }); + [this.getPendingAction, this.setPendingAction] = createSignal(""); + [this.getAtmView, this.setAtmView] = createSignal("pin"); + [this.getEnteredPin, this.setEnteredPin] = createSignal(""); + [this.getCustomAmount, this.setCustomAmount] = createSignal(""); + [this.getAccountVersion, this.setAccountVersion] = createSignal(0); + [this.getSessionVersion, this.setSessionVersion] = createSignal(0); + } + + finishAction() { + this.setPendingAction(""); + } + + hydrateFromPayload(payload) { + const mode = String(payload?.session?.mode || "bank") + .trim() + .toLowerCase(); + const currentMode = this.getMode(); + const currentAtmView = this.getAtmView(); + + this.setMode(mode === "atm" ? "atm" : "bank"); + this.setPendingAction(""); + this.setNotice({ text: "", type: "" }); + this.setEnteredPin(""); + this.setCustomAmount(""); + this.setAccountVersion(this.getAccountVersion() + 1); + this.setSessionVersion(this.getSessionVersion() + 1); + + if (mode === "atm") { + this.setAtmView(currentMode === "atm" ? currentAtmView : "pin"); + return; + } + + this.setAtmView("dashboard"); + } + + resetAtm() { + this.setEnteredPin(""); + this.setCustomAmount(""); + this.setAtmView("pin"); + } + + startAction(action) { + this.setPendingAction( + String(action || "") + .trim() + .toLowerCase(), + ); + } + } + + BankApp.store = new BankStore(); +})(); diff --git a/arma/client/addons/bank/ui/src/runtime.js b/arma/client/addons/bank/ui/src/runtime.js new file mode 100644 index 0000000..b51513e --- /dev/null +++ b/arma/client/addons/bank/ui/src/runtime.js @@ -0,0 +1,6 @@ +(function () { + const runtime = window.ForgeWebUI; + const BankApp = (window.BankApp = window.BankApp || {}); + BankApp.runtime = runtime; + window.AppRuntime = runtime; +})(); diff --git a/arma/client/addons/bank/ui/src/styles.css b/arma/client/addons/bank/ui/src/styles.css new file mode 100644 index 0000000..c418b82 --- /dev/null +++ b/arma/client/addons/bank/ui/src/styles.css @@ -0,0 +1,590 @@ +:root { + --bank-shell-bg: #f6f4ee; + --bank-surface: linear-gradient(180deg, #ffffff 0%, #f4f8fd 100%); + --bank-border: rgba(18, 54, 93, 0.12); + --bank-border-strong: rgba(18, 54, 93, 0.18); + --bank-text-main: #142f52; + --bank-text-muted: #6f86a3; + --bank-text-subtle: #8ea2bb; + --bank-accent: #275a8c; + --bank-accent-soft: #dfeaf9; + --bank-accent-line: rgba(39, 90, 140, 0.12); + --bank-shadow: 0 16px 30px rgba(18, 36, 57, 0.08); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#app { + width: 100%; + height: 100%; + margin: 0; +} + +body { + overflow: hidden; + background: transparent; + color: var(--bank-text-main); + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; +} + +button, +input, +select { + font: inherit; +} + +.bank-shell { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + background: var(--bank-shell-bg); +} + +.bank-scroll-shell { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; +} + +.bank-layout { + min-height: 100%; + width: min(100%, 1600px); + margin: 0 auto; + display: grid; + grid-template-columns: 320px minmax(0, 1fr); + gap: 1.25rem; + padding: 1.25rem; + flex: 1 0 auto; +} + +.bank-sidebar, +.bank-main { + min-height: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.bank-main { + overflow: visible; +} + +.bank-module, +.bank-card, +.bank-atm-panel { + background: var(--bank-surface); + border: 1px solid var(--bank-border); + border-radius: 1.3rem; + box-shadow: var(--bank-shadow); +} + +.bank-module, +.bank-card, +.bank-atm-panel { + padding: 1rem; + display: flex; + flex-direction: column; +} + +.bank-module-header, +.bank-card-header, +.bank-section-header, +.bank-page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.bank-module-header, +.bank-card-header { + margin-bottom: 0.9rem; +} + +.bank-page { + display: grid; + gap: 1.35rem; + padding: 0.1rem 0 0; +} + +.bank-page-header { + padding-top: 0.4rem; +} + +.bank-page-copy { + margin: 0; + color: var(--bank-text-muted); + line-height: 1.5; + max-width: 48rem; +} + +.bank-page-divider { + border-top: 1px solid var(--bank-accent-line); +} + +.bank-page-body { + display: grid; + gap: 1.25rem; + padding-bottom: 1.25rem; +} + +.bank-page-section { + display: grid; + gap: 1rem; + padding: 1.15rem 1.2rem 1.25rem; + border: 1px solid var(--bank-border); + border-radius: 1.3rem; + background: rgba(255, 255, 255, 0.72); + box-shadow: none; +} + +.bank-title, +.bank-section-title { + margin: 0; + color: var(--bank-text-main); + letter-spacing: -0.02em; +} + +.bank-title { + font-size: 1.7rem; +} + +.bank-section-title { + font-size: 1.1rem; +} + +.bank-eyebrow, +.bank-footer-title, +.bank-stat-label { + display: block; + font-size: 0.68rem; + letter-spacing: 0.16em; + text-transform: uppercase; + font-weight: 700; + color: var(--bank-text-subtle); +} + +.bank-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.48rem 0.8rem; + border-radius: 999px; + background: var(--bank-accent-soft); + color: var(--bank-accent); + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + white-space: nowrap; +} + +.bank-summary-grid, +.bank-profile-stack { + display: grid; + gap: 0.8rem; +} + +.bank-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.bank-stat-card, +.bank-metric-card { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.9rem; + border-radius: 0.95rem; + border: 1px solid var(--bank-border); + background: rgba(255, 255, 255, 0.6); +} + +.bank-stat-card.is-accent, +.bank-metric-card.is-accent { + background: linear-gradient(180deg, #edf4fe 0%, #dfeaf9 100%); +} + +.bank-stat-card.is-success, +.bank-metric-card.is-success { + background: linear-gradient(180deg, #edf9f4 0%, #dff4ea 100%); +} + +.bank-stat-card.is-warning, +.bank-metric-card.is-warning { + background: linear-gradient(180deg, #fdf7ea 0%, #f7edd4 100%); +} + +.bank-stat-value, +.bank-metric-value { + min-width: 0; + color: var(--bank-text-main); + font-weight: 700; + overflow-wrap: anywhere; +} + +.bank-stat-value { + font-size: 1rem; +} + +.bank-metric-value { + font-size: 1.8rem; + letter-spacing: -0.03em; +} + +.bank-metric-copy, +.bank-card-copy, +.bank-empty-copy, +.bank-footer-copy, +.bank-history-meta { + color: var(--bank-text-muted); + line-height: 1.45; +} + +.bank-card-copy { + margin: 0 0 0.9rem; +} + +.bank-summary-band { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.bank-action-sections { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.bank-support-sections { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 1rem; +} + +.bank-form-stack { + display: grid; + gap: 0.75rem; +} + +.bank-input, +.bank-select { + width: 100%; + min-width: 0; + height: 2.9rem; + padding: 0 0.95rem; + border-radius: 0.8rem; + border: 1px solid var(--bank-border); + background: rgba(255, 255, 255, 0.82); + color: var(--bank-text-main); +} + +.bank-action-row { + display: flex; + gap: 0.75rem; +} + +.bank-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.85rem; + padding: 0.75rem 1rem; + border-radius: 0.8rem; + border: 1px solid var(--bank-border); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; + transition: + background-color 160ms ease, + color 160ms ease, + border-color 160ms ease; +} + +.bank-btn:disabled { + opacity: 0.55; + cursor: default; +} + +.bank-btn-primary { + background: #455a77; + border-color: #455a77; + color: #fff; +} + +.bank-btn-primary:hover:not(:disabled) { + background: #354863; + border-color: #354863; +} + +.bank-btn-secondary { + background: rgba(255, 255, 255, 0.82); + color: var(--bank-accent); +} + +.bank-btn-secondary:hover:not(:disabled) { + background: #eef4fd; +} + +.bank-history-list { + display: grid; + gap: 0.75rem; +} + +.bank-history-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.85rem 0.95rem; + border-radius: 0.9rem; + border: 1px solid var(--bank-border); + background: rgba(255, 255, 255, 0.6); +} + +.bank-history-copy { + min-width: 0; + display: grid; + gap: 0.18rem; +} + +.bank-history-title, +.bank-empty-title { + color: var(--bank-text-main); + font-weight: 700; +} + +.bank-history-value { + white-space: nowrap; + font-weight: 700; + color: var(--bank-accent); +} + +.bank-empty-state { + display: grid; + gap: 0.35rem; + padding: 1rem 0; +} + +.bank-notice-stack { + position: fixed; + top: 1.2rem; + right: 1.5rem; + z-index: 12; + display: grid; + gap: 0.65rem; +} + +.bank-notice { + max-width: 24rem; + padding: 0.85rem 1rem; + border-radius: 0.9rem; + border: 1px solid var(--bank-border); + background: #fff; + box-shadow: 0 14px 28px rgba(16, 34, 56, 0.14); + font-size: 0.92rem; +} + +.bank-notice.is-success { + background: #ecfdf5; + border-color: #bbf7d0; + color: #166534; +} + +.bank-notice.is-error { + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; +} + +.bank-footer-bar { + width: 100%; + margin-top: auto; + background: #1e293b; + color: #f8fafc; +} + +.bank-footer { + width: min(100%, 1600px); + margin: 0 auto; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4rem; + padding: 3rem 1.25rem; +} + +.bank-footer-block { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.bank-footer-title { + margin: 0; + color: #f8fafc; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 700; + padding-bottom: 0.5rem; + border-bottom: 1px solid #475569; +} + +.bank-footer-list { + margin: 0; + padding: 0; + list-style: none; +} + +.bank-atm-shell { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 1rem; +} + +.bank-atm-panel { + width: min(100%, 560px); + display: grid; + gap: 1rem; +} + +.bank-atm-stack { + display: grid; + gap: 1rem; +} + +.bank-pin-display, +.bank-balance-display { + display: flex; + align-items: center; + justify-content: center; + min-height: 5rem; + padding: 1rem; + border-radius: 1rem; + border: 1px solid var(--bank-border-strong); + background: rgba(255, 255, 255, 0.68); + color: var(--bank-text-main); + text-align: center; +} + +.bank-pin-display { + font-size: 2rem; +} + +.bank-balance-display { + font-size: 2.5rem; + font-weight: 800; + letter-spacing: -0.03em; +} + +.bank-pin-indicators { + display: flex; + align-items: center; + justify-content: center; + gap: 0.9rem; +} + +.bank-pin-indicator { + width: 1rem; + height: 1rem; + border-radius: 999px; + border: 2px solid var(--bank-accent); + background: transparent; +} + +.bank-pin-indicator.is-filled { + background: var(--bank-accent); +} + +.bank-keypad { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.bank-key { + min-height: 3.2rem; + padding: 0.9rem; + border-radius: 0.9rem; + border: 1px solid var(--bank-border); + background: rgba(255, 255, 255, 0.82); + color: var(--bank-text-main); + font-weight: 700; +} + +.bank-key.is-muted { + background: #eef2f8; + color: var(--bank-text-muted); +} + +.bank-key.is-accent { + background: #455a77; + border-color: #455a77; + color: #fff; +} + +.bank-key.is-wide { + grid-column: span 3; +} + +.bank-atm-action-grid { + display: grid; + gap: 0.75rem; +} + +.bank-shell.is-atm { + background: transparent; + min-height: 100%; + justify-content: center; +} + +.bank-shell.is-atm .bank-atm-shell { + flex: 1; + width: 100%; + min-height: 100%; + max-width: 100%; +} + +.bank-footer-copy { + color: #cbd5e1; + line-height: 1.5; + margin: 0 0 0.75rem; +} + +@media (max-width: 1200px) { + .bank-layout { + grid-template-columns: 1fr; + } + + .bank-main { + overflow: visible; + } +} + +@media (max-width: 900px) { + .bank-summary-band, + .bank-action-sections, + .bank-footer { + grid-template-columns: 1fr; + } + + .bank-summary-grid { + grid-template-columns: 1fr; + } +} diff --git a/arma/client/addons/bank/ui/ui.config.mjs b/arma/client/addons/bank/ui/ui.config.mjs new file mode 100644 index 0000000..d323273 --- /dev/null +++ b/arma/client/addons/bank/ui/ui.config.mjs @@ -0,0 +1,38 @@ +export default { + addonName: "bank", + title: "FORGE Banking Console", + logLabel: "Bank UI", + outputDir: "_site", + jsBundles: [ + { + name: "Bank UI app", + output: "bank-ui.js", + sources: [ + "src/runtime.js", + "src/data.js", + "src/registry/store.js", + "src/bridge.js", + "src/registry/events.js", + "src/components/common.js", + "src/components/BankSidebar.js", + "src/components/Footer.js", + "src/pages/BankView.js", + "src/pages/ATMView.js", + "src/components/AppShell.js", + "src/bootstrap.js", + ], + }, + ], + cssBundles: [ + { + name: "Bank UI styles", + output: "bank-ui.css", + sources: ["src/styles.css"], + }, + ], + site: { + styles: ["bank-ui.css"], + commonScripts: ["forge-webui.js"], + scripts: ["bank-ui.js"], + }, +}; diff --git a/arma/client/addons/common/README.md b/arma/client/addons/common/README.md index 9ce0564..05fa06e 100644 --- a/arma/client/addons/common/README.md +++ b/arma/client/addons/common/README.md @@ -1,4 +1,5 @@ -forge_client_common -=================== +# forge_client_common Common functionality shared between addons. + +See [WEB_UI_FRAMEWORK.md](./WEB_UI_FRAMEWORK.md) for the proposed shared `CT_WEBBROWSER` UI framework layout and API. diff --git a/arma/client/addons/common/WEB_UI_FRAMEWORK.md b/arma/client/addons/common/WEB_UI_FRAMEWORK.md new file mode 100644 index 0000000..be0077f --- /dev/null +++ b/arma/client/addons/common/WEB_UI_FRAMEWORK.md @@ -0,0 +1,991 @@ +# Web UI Framework Proposal + +## Goal + +Create a shared web UI framework inside `forge_client_common` that provides one browser runtime for all `CT_WEBBROWSER` interfaces: + +- store +- bank +- garage +- org +- actor +- notifications + +The framework should standardize: + +- browser bootstrapping +- Arma to JS messaging +- JS to Arma messaging +- reactive state updates +- shared UI primitives +- asset loading +- teardown and remount behavior + +## Why This Should Live In `common` + +The current client web UIs already share the same underlying concerns: + +- `A3API.RequestFile` for loading scripts and styles +- `A3API.SendAlert` for outbound events +- `ctrlWebBrowserAction ["ExecJS", ...]` for inbound events +- full-page rerender on every signal update +- duplicated runtime and bridge code across addons + +That makes `forge_client_common` the right owner for: + +- the browser runtime +- the bridge contract +- reusable DOM helpers +- shared components and styles + +Each addon should keep only: + +- app-specific state +- app-specific event names +- app-specific SQF handlers +- app-specific views and theme assets + +## Constraints From `CT_WEBBROWSER` + +This framework should be built for the actual browser host, not for a generic modern frontend stack. + +- Browser engine should be treated as conservative Chromium/CEF. +- HTML is hosted inside the Arma browser control, not a normal web server app. +- Asset loading must work through `A3API.RequestFile`. +- Game integration must work through `A3API.SendAlert` and SQF `ExecJS`. +- Browser controls are opened and destroyed by UI displays, so mount/unmount must be explicit. +- Startup latency matters because players open these UIs interactively in-game. + +## Design Principles + +1. Keep the runtime small. +2. Avoid framework dependencies like React or Vue. +3. Prefer one shared bundle plus one app bundle per UI. +4. Support coarse-grained reactivity first, then targeted DOM patching where it matters. +5. Make the Arma bridge a first-class host adapter, not an afterthought. +6. Keep app logic plain JavaScript so views are easy to reason about. +7. Make every UI follow the same bootstrap contract. + +## Proposed Ownership + +### Common addon + +`forge_client_common` should own: + +- browser host adapter +- reactive runtime +- DOM renderer +- shared event bus +- base CSS tokens and utility classes +- shared components +- generic bootstrap helper +- SQF bridge base class + +### Feature addons + +Each feature addon should own: + +- one app entrypoint +- feature store/state +- feature bridge schema +- feature views/components +- feature-specific CSS layer +- feature SQF bridge subclass/instance + +## Proposed Folder Layout + +```text +arma/client/addons/common/ + ui/ + src/ + runtime.js + host.js + bridge.js + app.js + index.js + _site/ + forge-webui.js + functions/ + fnc_initWebUIBridge.sqf + fnc_openWebUI.sqf + fnc_sendWebUIEvent.sqf + README.md + WEB_UI_FRAMEWORK.md +``` + +Feature addon structure would then look like: + +```text +arma/client/addons/org/ + ui/ + _site/ + index.html + app.js + views/ + components/ + theme.css + functions/ + fnc_initOrgUIBridge.sqf + fnc_openUI.sqf + fnc_handleUIEvents.sqf +``` + +## Runtime API Sketch + +The shared runtime should expose a small API on `window.ForgeWebUI`. + +### Core API + +```js +ForgeWebUI = { + h, + text, + fragment, + signal, + computed, + effect, + batch, + mount, + unmount, + createApp, + createBridge, + createAssetLoader, + createNoticeCenter, +}; +``` + +### Reactive primitives + +```js +const count = signal(0); +const doubled = computed(() => count() * 2); + +effect(() => { + console.log("count", count()); +}); + +count.set(5); +``` + +Design notes: + +- `signal()` returns a getter function with `.set()` and `.update()`. +- `computed()` caches until one of its dependencies changes. +- `effect()` is for bridge sync, timers, DOM subscriptions, and cleanup. +- `batch()` groups several writes into one render pass. + +### DOM/rendering + +```js +function CounterView() { + return h("button", { + onClick() { + count.update((value) => value + 1); + } + }, `Count: ${count()}`); +} + +mount(document.getElementById("app"), CounterView); +``` + +The renderer should support: + +- keyed child reconciliation +- event binding +- text node updates +- conditional sections +- list rendering +- SVG nodes +- mount cleanup + +It should not rebuild the whole root on every write. + +## App Bootstrap Contract + +Every app should use the same bootstrap shape: + +```js +const app = ForgeWebUI.createApp({ + name: "org", + root: "#app", + setup({ host, bridge, assets, notices }) { + const store = createOrgStore(); + + bridge.on("org::sync", (payload) => { + store.hydrate(payload); + }); + + bridge.ready(); + + return () => OrgApp({ store, host, notices }); + } +}); + +app.start(); +``` + +Responsibilities: + +- `createApp()` locates the root node +- waits for DOM readiness +- sets up host services +- mounts the view +- wires bridge event listeners +- exposes teardown hooks + +## Host Adapter API + +The Arma host layer should hide `A3API` details behind one consistent service. + +```js +const host = { + isArma: true, + requestFile(path), + requestTexture(path, size), + send(event, data), + exec(name, data), + on(event, handler), + off(event, handler), + ready(data), + close(data), +}; +``` + +Behavior: + +- `send()` wraps `A3API.SendAlert(JSON.stringify(...))` +- `on()` and `off()` subscribe to messages injected from SQF +- `ready()` announces page readiness to SQF +- `close()` sends a standard close event +- if `A3API` is unavailable, fallback behavior supports local browser testing + +## JS Bridge Contract + +Each page should expose one stable bridge object to SQF: + +```js +window.ForgeBridge.receive({ + event: "org::sync", + data: { ... } +}); +``` + +This replaces app-specific globals like: + +- `StoreUIBridge` +- `OrgUIBridge` + +Recommended interface: + +```js +window.ForgeBridge = { + receive(payload), + receiveMany(events), + reset(), + ping(), +}; +``` + +Feature apps should register handlers with the shared bridge: + +```js +bridge.on("store::hydrate", handleHydrate); +bridge.on("store::checkout::success", handleCheckoutSuccess); +``` + +That removes duplicated payload parsing from each app bridge file. + +## SQF Bridge Base Class + +The SQF side should also be normalized in `common`. + +### Shared base responsibilities + +- find active browser control +- execute JS safely +- send `{ event, data }` payloads +- queue payloads until page ready +- flush pending payloads on ready +- standardize close handling +- standardize logging and diagnostics + +### SQF API sketch + +```sqf +GVAR(WebUIBridgeBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "WebUIBridgeBaseClass"], + ["#create", compileFinal { + _self set ["pendingEvents", []]; + _self set ["isReady", false]; + }], + ["getActiveBrowserControl", compileFinal { ... }], + ["execJS", compileFinal { ... }], + ["sendEvent", compileFinal { ... }], + ["queueEvent", compileFinal { ... }], + ["flushPendingEvents", compileFinal { ... }], + ["handleReady", compileFinal { ... }], + ["handleClose", compileFinal { ... }] +]; +``` + +Feature bridges like org or store would then extend only the behavior they need: + +- payload building +- server RPC dispatch +- feature response mapping + +## SQF Type Model With `createHashMapObject` + +The SQF side should lean into `createHashMapObject` instead of using plain hash maps for everything. + +This gives us: + +- inheritance through `#base` +- explicit type tagging through `#type` +- constructors through `#create` +- cleanup through `#delete` + +That is a strong fit for browser UI infrastructure because the UI layer already has clear object roles. + +### Recommended types + +At minimum, define these object families in `forge_client_common`: + +- `IWebUIBridge` +- `IWebUIScreen` +- `IWebUIRequest` +- `IWebUISubscription` + +Feature addons can then define their own types on top: + +- `OrgUIBridge` +- `StoreUIBridge` +- `BankUIBridge` +- `GarageUIBridge` + +### Example hierarchy + +```sqf +private _webUIBridgeDeclaration = [ + ["#type", "IWebUIBridge"], + ["#create", { ... }], + ["getActiveBrowserControl", { ... }], + ["sendEvent", { ... }], + ["handleReady", { ... }], + ["dispose", { ... }] +]; + +private _orgUIBridgeDeclaration = [ + ["#base", _webUIBridgeDeclaration], + ["#type", "OrgUIBridge"], + ["buildHydratePayload", { ... }], + ["handleCreditResponse", { ... }] +]; +``` + +Type checks then become straightforward: + +```sqf +if ("IWebUIBridge" in (_bridge get "#type")) then { + _bridge call ["sendEvent", ["ui::ping", createHashMap]]; +}; +``` + +### Why Example 4 matters + +Example 4 on the wiki shows the important lifecycle property: + +- constructor creates a resource +- object holds that resource +- destructor deletes that resource when the object is released + +That pattern maps directly to UI/session resources. + +### Good uses of `#delete` in this framework + +- clear pending request queues +- unregister display event handlers +- null out active browser control references +- stop polling/update loops +- remove temporary mission event handlers +- release temporary response trackers + +### Example use: request/response object + +```sqf +private _requestDeclaration = [ + ["#type", "IWebUIRequest"], + ["#create", { + params ["_requestId", "_onTimeout"]; + _self set ["requestId", _requestId]; + _self set ["onTimeout", _onTimeout]; + _self set ["isResolved", false]; + }], + ["resolve", { + _self set ["isResolved", true]; + }], + ["#delete", { + if !(_self getOrDefault ["isResolved", false]) then { + private _onTimeout = _self getOrDefault ["onTimeout", {}]; + call _onTimeout; + }; + }] +]; +``` + +This is the same concept as Example 4: + +- object owns a resource or responsibility +- when the object is released, cleanup happens automatically + +## Lifecycle Guidance + +Use destructors as a cleanup safety net, not as the only control path. + +Reason: + +- `#delete` runs when the last reference is removed +- that is useful, but not always the best moment for gameplay/UI logic + +Recommended pattern: + +1. expose an explicit `dispose` or `close` method +2. perform normal cleanup there +3. let `#delete` catch anything missed + +That keeps UI shutdown deterministic while still benefiting from automatic cleanup. + +## Typed Screen Objects + +We can also model each open browser UI as a typed screen object instead of just storing a control reference. + +Example: + +```sqf +private _screenDeclaration = [ + ["#type", "IWebUIScreen"], + ["#create", { + params ["_displayName", "_control"]; + _self set ["displayName", _displayName]; + _self set ["control", _control]; + _self set ["isReady", false]; + _self set ["pendingEvents", []]; + }], + ["markReady", { + _self set ["isReady", true]; + }], + ["queueEvent", { ... }], + ["flushPendingEvents", { ... }], + ["dispose", { + _self set ["pendingEvents", []]; + _self set ["control", controlNull]; + }] +]; +``` + +That gives us a cleaner split: + +- bridge object owns app-level behavior +- screen object owns one live browser control/session +- request objects own transient async work + +## Recommended Application To Current Addons + +The current org and store bridge objects already use `createHashMapObject`. + +This should evolve into: + +- one shared `IWebUIBridge` base declaration in `common` +- one shared `IWebUIScreen` declaration in `common` +- feature bridge types inheriting from `IWebUIBridge` +- optional transient request/session helper types where async cleanup matters + +That will make the SQF side more explicit, easier to test, and safer around UI teardown. + +## Event Naming + +Keep namespaced events. The current event style is good. + +Examples: + +- `org::ready` +- `org::sync` +- `org::create::request` +- `store::checkout::request` +- `notifications::ready` + +Standardize a small set of host-level events: + +- `ui::ready` +- `ui::close` +- `ui::error` +- `ui::ping` + +And keep feature events under their own namespace. + +## State Model + +The framework should support two store patterns: + +### Local signal store + +Good for: + +- form state +- modal state +- selection state +- optimistic UI flags + +### Domain store wrapper + +Good for: + +- hydrated server payloads +- catalog data +- actor action lists +- organization portal data + +Recommended store API: + +```js +function createStore(initialState) { + const state = signal(initialState); + + return { + get state() { + return state(); + }, + patch(partial) { + state.set({ ...state(), ...partial }); + }, + replace(next) { + state.set(next); + } + }; +} +``` + +## Component Update Model + +The framework should update component subtrees, not the full UI root. + +That means: + +- no browser page reload +- no `innerHTML = ""` on the app root for every state change +- only components that read changed state should rerender + +### Practical expectation + +Examples: + +- adding a member updates `MembersCard` and any member count badge +- granting a credit line updates `TreasuryCard` and the specific member row +- updating funds updates treasury summary components only +- showing a modal or notice updates only the overlay layer + +## Store Contract + +Each app store should expose three layers: + +1. domain state signals +2. derived selectors/computed values +3. mutation methods + +Recommended shape: + +```js +function createOrgStore() { + const org = signal({ + id: "", + name: "", + ownerUid: "", + }); + + const session = signal({ + actorUid: "", + actorName: "", + role: "", + ceo: false, + }); + + const treasury = signal({ + funds: 0, + reputation: 0, + creditLines: [], + }); + + const roster = signal({ + members: [], + }); + + const ui = signal({ + modal: null, + notices: [], + treasuryTab: "overview", + }); + + const memberCount = computed(() => roster().members.length); + const activeCreditCount = computed(() => treasury().creditLines.length); + + return { + org, + session, + treasury, + roster, + ui, + memberCount, + activeCreditCount, + hydrate(payload) { ... }, + addMember(member) { ... }, + removeMember(memberUid) { ... }, + upsertCreditLine(line) { ... }, + setFunds(amount) { ... }, + openModal(type, data) { ... }, + closeModal() { ... }, + }; +} +``` + +### Rules + +- component code reads signals directly from the store +- mutation methods are the only place that update domain state +- derived values use `computed()` instead of recalculating in every component +- UI state stays separate from domain state + +## Component Contract + +Components should be plain functions that subscribe only to the signals they read. + +Example: + +```js +function MembersCard({ store, actions }) { + const members = store.roster().members; + const canManageMembers = store.canManageMembers(); + + return Card({ + title: "Members", + body: List({ + items: members, + key: (member) => member.uid, + renderItem: (member) => + MemberRow({ + member, + canRemove: canManageMembers && !store.isProtectedMember(member), + onRemove: () => actions.removeMember(member.uid), + }), + }), + }); +} +``` + +In this model: + +- `MembersCard` rerenders when `roster().members` changes +- it does not rerender when treasury funds change +- `TreasuryCard` rerenders when `treasury()` changes +- modal components rerender when `ui().modal` changes + +## Patch-Oriented Mutations + +Interactive actions should prefer small patch events over full app hydration. + +Recommended event examples: + +- `org::member::added` +- `org::member::removed` +- `org::member::creditUpdated` +- `org::treasury::fundsUpdated` +- `org::notice::show` + +Initial load can still use a hydrate event: + +- `org::hydrate` + +But actions like assigning credit lines should not require rebuilding the full portal payload. + +Example: + +```js +bridge.on("org::member::creditUpdated", ({ memberUid, memberName, amount }) => { + store.upsertCreditLine({ + uid: memberUid, + member: memberName, + amount, + }); +}); +``` + +## List Reconciliation + +To make targeted updates real, list rendering must be keyed. + +Requirement: + +- every repeated domain item must have a stable key + +Examples: + +- members use `uid` +- credit lines use `uid` +- assets use `className` or inventory id +- fleet entries use vehicle id + +Without keyed reconciliation, a list change still forces the entire list DOM to be replaced. + +## Org UI Example + +Using the current organization portal as the model: + +### `MembersCard` + +Depends on: + +- `store.roster().members` +- membership permission selectors + +Should update when: + +- a member is added +- a member is removed +- a member name or role changes + +Should not update when: + +- treasury funds change +- a modal opens +- a fleet item changes + +### `TreasuryCard` + +Depends on: + +- `store.treasury().funds` +- `store.treasury().creditLines` +- treasury permissions +- `store.ui().treasuryTab` + +Should update when: + +- funds change +- a credit line is added or updated +- the user changes treasury tab + +Should not update when: + +- member roster changes unrelated to treasury display +- fleet changes + +### `ModalLayer` + +Depends on: + +- `store.ui().modal` + +Should update when: + +- a modal opens +- a modal closes +- modal payload changes + +Should not update when unrelated domain state changes. + +## Mutation Examples + +### Add member + +```js +addMember(member) { + this.roster.update((state) => ({ + ...state, + members: [...state.members, member], + })); +} +``` + +Only subscribers to `roster` rerender. + +### Update credit line + +```js +upsertCreditLine(nextLine) { + this.treasury.update((state) => { + const exists = state.creditLines.some((line) => line.uid === nextLine.uid); + + return { + ...state, + creditLines: exists + ? state.creditLines.map((line) => + line.uid === nextLine.uid ? nextLine : line + ) + : [...state.creditLines, nextLine], + }; + }); +} +``` + +Only subscribers to `treasury` rerender. + +## Bridge Response Strategy + +For responsive UIs, each server-backed action should define: + +- request event +- success patch event +- failure notice event or payload + +Example credit line flow: + +1. JS sends `org::credit::request` +2. SQF/server validates and persists +3. SQF sends: + - `org::member::creditUpdated` on success + - `org::credit::failure` on failure +4. JS store applies a targeted patch +5. `TreasuryCard` and any dependent member row update + +This is preferable to sending a full `org::sync` after every action. + +## Shared Components + +The common addon should provide plain, themeable primitives only. + +Recommended first set: + +- app shell +- title bar +- navbar +- modal +- notice/toast +- stat card +- empty state +- action row +- form field +- spinner +- error banner + +These should accept data and callbacks, not own business logic. + +## Styling Model + +Use layered CSS: + +1. common tokens +2. common primitives +3. feature theme +4. feature view styles + +The common layer should define: + +- spacing scale +- type scale +- colors +- elevation/shadows +- radius +- focus states +- motion durations + +Feature UIs should override tokens rather than rewriting primitive CSS. + +## Asset Loading + +The loader should support: + +- `A3API.RequestFile` +- `A3API.RequestTexture` +- local `fetch()` fallback for browser testing + +Recommended change: + +- stop loading many small scripts individually in production +- build one common runtime file and one feature app file +- keep source files split in repo, but ship bundled outputs into `_site` + +That reduces browser startup cost and simplifies ordering problems. + +## Error Handling + +The framework should standardize: + +- bridge unavailable errors +- malformed payload errors +- timeout handling for requests that expect responses +- visible in-UI notices for recoverable failures +- `console.error` plus `diag_log` friendly payloads + +Recommended bridge helper: + +```js +bridge.request("store::checkout::request", payload, { + pending: "Submitting order...", + timeoutMs: 15000, + onTimeout() { + notices.error("The checkout request timed out."); + } +}); +``` + +## Migration Plan + +### Phase 1 + +Extract common pieces without changing app behavior: + +- shared JS host adapter +- shared JS bridge +- shared signal/runtime +- shared SQF bridge base class + +### Phase 2 + +Migrate `org` and `store` first because they already use the same custom runtime pattern. + +### Phase 3 + +Migrate `bank`, `garage`, and `notifications`. + +### Phase 4 + +Migrate `actor`, which may need more event-heavy interaction handling. + +### Phase 5 + +Bundle all `_site` apps into production-ready outputs. + +## First Implementation Targets + +The first concrete files to build should be: + +1. `arma/client/addons/common/ui/src/host.js` +2. `arma/client/addons/common/ui/src/runtime.js` +3. `arma/client/addons/common/ui/src/bridge.js` +4. `arma/client/addons/common/ui/src/app.js` +5. `arma/client/addons/common/functions/fnc_initWebUIBridge.sqf` + +Those five pieces establish the core contract. After that, `org` and `store` can be migrated with low risk. + +## Non-Goals + +At least initially, this framework should not try to provide: + +- client-side routing between pages +- SSR or pre-rendering +- JSX compilation +- TypeScript-only tooling assumptions +- a giant component system +- generalized diffing for every possible DOM edge case + +This should stay focused on Arma in-browser application UIs. + +## Recommended Direction + +Use `forge_client_common` as the host for a small custom reactive framework, not as a dumping ground for copied app utilities. + +The correct abstraction boundary is: + +- `common` owns the browser platform +- each addon owns the application + +That gives one UI system across the repo without forcing all screens into one monolithic app. diff --git a/arma/client/addons/common/XEH_PREP.hpp b/arma/client/addons/common/XEH_PREP.hpp index 8b13789..ae721c8 100644 --- a/arma/client/addons/common/XEH_PREP.hpp +++ b/arma/client/addons/common/XEH_PREP.hpp @@ -1 +1,2 @@ +PREP(initWebUIBridge); diff --git a/arma/client/addons/common/functions/fnc_initWebUIBridge.sqf b/arma/client/addons/common/functions/fnc_initWebUIBridge.sqf new file mode 100644 index 0000000..15450d6 --- /dev/null +++ b/arma/client/addons/common/functions/fnc_initWebUIBridge.sqf @@ -0,0 +1,209 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initWebUIBridge.sqf + * Author: IDSolutions + * Date: 2026-03-13 + * Last Update: 2026-03-13 + * Public: No + * + * Description: + * Initializes the shared web UI bridge and screen declarations used by + * CT_WEBBROWSER feature bridges. + * + * Arguments: + * None + * + * Return Value: + * Web UI bridge declarations [HASHMAP] + * + * Example: + * call forge_client_common_fnc_initWebUIBridge + */ + +if !(isNil QGVAR(WebUIScreenDeclaration) || { isNil QGVAR(WebUIBridgeDeclaration) }) exitWith { + createHashMapFromArray [ + ["bridgeDeclaration", GVAR(WebUIBridgeDeclaration)], + ["screenDeclaration", GVAR(WebUIScreenDeclaration)] + ] +}; + +#pragma hemtt ignore_variables ["_self"] +GVAR(WebUIScreenDeclaration) = compileFinal createHashMapFromArray [ + ["#type", "IWebUIScreen"], + ["#create", compileFinal { + params [["_control", controlNull, [controlNull]]]; + + _self set ["control", _control]; + _self set ["readyState", false]; + _self set ["pendingEvents", []]; + }], + ["dispose", compileFinal { + _self set ["control", controlNull]; + _self set ["readyState", false]; + _self set ["pendingEvents", []]; + + true + }], + ["getControl", compileFinal { + _self getOrDefault ["control", controlNull] + }], + ["consumePendingEvents", compileFinal { + private _pendingEvents = +(_self getOrDefault ["pendingEvents", []]); + _self set ["pendingEvents", []]; + + _pendingEvents + }], + ["isReady", compileFinal { + _self getOrDefault ["readyState", false] + }], + ["markReady", compileFinal { + params [["_isReady", true, [false]]]; + + _self set ["readyState", _isReady]; + _isReady + }], + ["queueEvent", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _pendingEvents = +(_self getOrDefault ["pendingEvents", []]); + _pendingEvents pushBack _payload; + _self set ["pendingEvents", _pendingEvents]; + + count _pendingEvents + }], + ["setControl", compileFinal { + params [["_control", controlNull, [controlNull]]]; + + _self set ["control", _control]; + _control + }], + ["#delete", compileFinal { + _self call ["dispose", []]; + }] +]; + +GVAR(WebUIBridgeDeclaration) = compileFinal createHashMapFromArray [ + ["#type", "IWebUIBridge"], + ["#create", compileFinal { + _self set ["screen", createHashMapObject [GVAR(WebUIScreenDeclaration)]]; + }], + ["deliverPayload", compileFinal { + params [["_control", controlNull, [controlNull]], ["_payload", createHashMap, [createHashMap]]]; + + if (isNull _control) exitWith { false }; + + private _json = toJSON _payload; + _control ctrlWebBrowserAction ["ExecJS", format ["ForgeBridge.receive(%1)", _json]]; + + true + }], + ["execJS", compileFinal { + params [["_control", controlNull, [controlNull]], ["_statement", "", [""]]]; + + if (isNull _control || { _statement isEqualTo "" }) exitWith { false }; + + _control ctrlWebBrowserAction ["ExecJS", _statement]; + true + }], + ["flushPendingEvents", compileFinal { + private _screen = _self call ["getScreen", []]; + private _control = _self call ["getActiveBrowserControl", []]; + if (isNull _control) exitWith { 0 }; + + private _pendingEvents = _screen call ["consumePendingEvents", []]; + + { + _self call ["deliverPayload", [_control, _x]]; + } forEach _pendingEvents; + + count _pendingEvents + }], + ["getActiveBrowserControl", compileFinal { + private _screen = _self call ["getScreen", []]; + _screen call ["getControl", []] + }], + ["getScreen", compileFinal { + private _hasScreen = "screen" in _self; + private _screen = if (_hasScreen) then { + _self get "screen" + } else { + createHashMap + }; + + if (!_hasScreen) then { + _screen = createHashMapObject [GVAR(WebUIScreenDeclaration)]; + _self set ["screen", _screen]; + }; + + _screen + }], + ["handleClose", compileFinal { + private _screen = _self call ["getScreen", []]; + _screen call ["dispose", []] + }], + ["handleReady", compileFinal { + params [["_control", controlNull, [controlNull]], ["_data", createHashMap, [createHashMap]]]; + + private _screen = _self call ["getScreen", []]; + _screen call ["setControl", [_control]]; + _screen call ["markReady", [true]]; + + _self call ["flushPendingEvents", []]; + true + }], + ["queueEvent", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _screen = _self call ["getScreen", []]; + _screen call ["queueEvent", [_payload]] + }], + ["sendEvent", compileFinal { + params [ + ["_event", "", [""]], + ["_data", createHashMap, [createHashMap]], + ["_control", controlNull, [controlNull]] + ]; + + if (_event isEqualTo "") exitWith { false }; + + private _payload = createHashMapFromArray [ + ["event", _event], + ["data", _data] + ]; + private _screen = _self call ["getScreen", []]; + private _targetControl = _control; + + if (isNull _targetControl) then { + _targetControl = _self call ["getActiveBrowserControl", []]; + }; + + if (isNull _targetControl) exitWith { + _self call ["queueEvent", [_payload]]; + false + }; + + _screen call ["setControl", [_targetControl]]; + + if !(_screen call ["isReady", []]) exitWith { + _self call ["queueEvent", [_payload]]; + false + }; + + _self call ["deliverPayload", [_targetControl, _payload]] + }], + ["setActiveBrowserControl", compileFinal { + params [["_control", controlNull, [controlNull]]]; + + private _screen = _self call ["getScreen", []]; + _screen call ["setControl", [_control]] + }], + ["#delete", compileFinal { + _self call ["handleClose", []]; + }] +]; + +createHashMapFromArray [ + ["bridgeDeclaration", GVAR(WebUIBridgeDeclaration)], + ["screenDeclaration", GVAR(WebUIScreenDeclaration)] +] diff --git a/arma/client/addons/common/ui/_site/forge-site-loader.js b/arma/client/addons/common/ui/_site/forge-site-loader.js new file mode 100644 index 0000000..14d7756 --- /dev/null +++ b/arma/client/addons/common/ui/_site/forge-site-loader.js @@ -0,0 +1 @@ +!function(e){const o=e.ForgeSiteLoader=e.ForgeSiteLoader||{};function t(e){return"string"==typeof e&&e.startsWith("forge\\")}function r({addonRoot:e,browserBase:o,assetPath:r}){if("undefined"!=typeof A3API&&A3API&&"function"==typeof A3API.RequestFile){const o=t(r)?r:e+String(r||"").replace(/\//g,"\\");return A3API.RequestFile(o)}const n=t(r)?r:function(e,o){return`${String(e||"./").replace(/\\/g,"/")}${String(o||"").replace(/\\/g,"/")}`}(o,r);return fetch(n).then(e=>{if(!e.ok)throw new Error(`Failed to load ${n}`);return e.text()})}function n(e){const o=document.createElement("style");o.textContent=e,document.head.appendChild(o)}function a(e){const o=document.createElement("script");o.text=e,document.head.appendChild(o)}async function i(e){const o=e&&e.addonName?e.addonName:"";if(!o)throw new Error("ForgeSiteLoader requires a config.addonName value.");const t=function(e){return`forge\\forge_client\\addons\\${e}\\ui\\_site\\`}(o),i=e.browserAddonBase||"./",s=e.browserCommonBase||"../../../common/ui/_site/",c=Array.isArray(e.styles)?e.styles:[],d=Array.isArray(e.commonScripts)?e.commonScripts:[],f=Array.isArray(e.scripts)?e.scripts:[];(await Promise.all(c.map(e=>r({addonRoot:t,browserBase:i,assetPath:e})))).forEach(n);(await Promise.all(d.map(e=>r({addonRoot:"forge\\forge_client\\addons\\common\\ui\\_site\\",browserBase:s,assetPath:e})))).forEach(a);(await Promise.all(f.map(e=>r({addonRoot:t,browserBase:i,assetPath:e})))).forEach(a)}o.boot=i,e.ForgeSiteConfig&&!1!==e.ForgeSiteConfig.autoBoot&&i(e.ForgeSiteConfig).catch(o=>{const t=e.ForgeSiteConfig.logLabel||e.ForgeSiteConfig.addonName||"Forge UI";console.error(`[${t}] Failed to load site assets.`,o)})}(window); \ No newline at end of file diff --git a/arma/client/addons/common/ui/_site/forge-webui.js b/arma/client/addons/common/ui/_site/forge-webui.js new file mode 100644 index 0000000..66fb106 --- /dev/null +++ b/arma/client/addons/common/ui/_site/forge-webui.js @@ -0,0 +1 @@ +!function(e){const n=e.ForgeWebUI=e.ForgeWebUI||{},t=new Set(["svg","path","circle","rect","line","polyline","polygon","g","defs","use","text","tspan","clipPath","mask"]),o=new Set,r=new Set;let i=null,c=0,a=!1;function l(){for(;r.size>0;){const e=Array.from(r);r.clear(),e.forEach(e=>u(e))}}function s(e){if("function"==typeof e.cleanup)try{e.cleanup()}catch(e){console.error("[ForgeWebUI] Observer cleanup failed.",e)}e.cleanup=null,e.dependencies.forEach(n=>{n.delete(e)}),e.dependencies.clear()}function u(e){if(!e||e.disposed)return;s(e);const n=i;i=e;try{const n=e.fn();"function"==typeof n&&(e.cleanup=n)}catch(e){console.error("[ForgeWebUI] Observer execution failed.",e)}finally{i=n}}function d(e){e&&!e.disposed&&(r.add(e),a||c>0||(a=!0,queueMicrotask(()=>{a=!1,l()})))}function f(e){let n=e;const t=new Set;function o(){var e;return e=t,i&&(e.add(i),i.dependencies.add(e)),n}return o.peek=()=>n,o.set=e=>{const o="function"==typeof e?e(n):e;return Object.is(o,n)||(n=o,t.forEach(e=>d(e))),n},o.update=e=>o.set(e),o.subscribe=e=>b(()=>{e(o())}),o}function b(e){const n={cleanup:null,dependencies:new Set,disposed:!1,fn:e,dispose:()=>{n.disposed||(n.disposed=!0,r.delete(n),s(n))}};return u(n),n.dispose}function p(e,n){null!=n&&!1!==n&&(Array.isArray(n)?n.forEach(n=>p(e,n)):"string"!=typeof n&&"number"!=typeof n&&"bigint"!=typeof n?n instanceof Node&&e.appendChild(n):e.appendChild(document.createTextNode(String(n))))}function m(...e){const n=document.createDocumentFragment();return e.forEach(e=>p(n,e)),n}function w(e){return document.createTextNode(String(e??""))}function g(e){return null==e||!1===e?document.createDocumentFragment():Array.isArray(e)?m(...e):"string"==typeof e||"number"==typeof e||"bigint"==typeof e?w(e):e instanceof Node?e:document.createDocumentFragment()}function y(e,n,t={}){const o=!1!==t.preserveScroll,r=b(()=>{const t=o?function(e){return Array.from(e.querySelectorAll("[data-preserve-scroll-id]")).map(e=>({id:e.getAttribute("data-preserve-scroll-id"),scrollLeft:e.scrollLeft,scrollTop:e.scrollTop}))}(e):[],r=g(n());e.replaceChildren(r),o&&t.length>0&&requestAnimationFrame(()=>{!function(e,n){Array.isArray(n)&&0!==n.length&&n.forEach(n=>{if(!n||!n.id)return;const t=e.querySelector(`[data-preserve-scroll-id="${n.id}"]`);t&&(t.scrollTop=Number(n.scrollTop||0),t.scrollLeft=Number(n.scrollLeft||0))})}(e,t)})});return{container:e,dispose:r,rerender(){e.replaceChildren(g(n()))}}}n.batch=function(e){c+=1;try{return e()}finally{c=Math.max(0,c-1),0===c&&l()}},n.computed=function(e){const n=f(void 0);let t=!1;return b(()=>{const o=e();t&&Object.is(o,n.peek())||(t=!0,n.set(o))}),n},n.createSignal=function(e){const n=f(e);return[n,n.set]},n.effect=b,n.ensureScopedStyle=function(e,n){if(!e||!n||o.has(e))return;const t=document.createElement("style");t.setAttribute("data-ui-style",e),t.textContent=n,document.head.appendChild(t),o.add(e)},n.fragment=m,n.h=function(e,n={},...o){const r=t.has(e),i=r?document.createElementNS("http://www.w3.org/2000/svg",e):document.createElement(e);return n&&"object"==typeof n&&Object.entries(n).forEach(([e,n])=>{!function(e,n,t,o){"key"!==n&&("ref"!==n||"function"!=typeof t?"className"!==n?"style"===n&&t&&"object"==typeof t?Object.assign(e.style,t):"dataset"===n&&t&&"object"==typeof t?Object.entries(t).forEach(([n,t])=>{e.dataset[n]=t}):n.startsWith("on")&&"function"==typeof t?e.addEventListener(n.slice(2).toLowerCase(),t):"value"===n&&"value"in e?e.value=t??"":"checked"===n&&"checked"in e?e.checked=Boolean(t):"selected"===n&&"selected"in e?e.selected=Boolean(t):"boolean"!=typeof t?null!=t?e.setAttribute(n,t):e.removeAttribute(n):t?e.setAttribute(n,""):e.removeAttribute(n):o?e.setAttribute("class",t||""):e.className=t||"":t(e))}(i,e,n,r)}),o.forEach(e=>p(i,e)),i},n.mount=y,n.render=function(e,n,t={}){return y(n,e,t)},n.signal=f,n.text=w,n.unmount=function(e){e&&"function"==typeof e.dispose&&e.dispose()}}(window),function(e){(e.ForgeWebUI=e.ForgeWebUI||{}).createHost=function(){const n=e.A3API;return{isArma:Boolean(n),close(e="ui::close",n={}){return this.send(e,n)},exec:e=>!(!n||"function"!=typeof n.Exec||"string"!=typeof e)&&(n.Exec(e),!0),requestFile:e=>n&&"function"==typeof n.RequestFile?n.RequestFile(e):fetch(e).then(n=>{if(!n.ok)throw new Error(`Failed to load ${e}`);return n.text()}),requestTexture:(e,t=512)=>n&&"function"==typeof n.RequestTexture?n.RequestTexture(e,t):Promise.reject(new Error("Texture requests are unavailable outside Arma.")),send:(e,t={})=>!(!n||"function"!=typeof n.SendAlert||"string"!=typeof e||""===e)&&(n.SendAlert(JSON.stringify({event:e,data:t})),!0)}}}(window),function(e){const n=e.ForgeWebUI=e.ForgeWebUI||{};n.createBridge=function(t={}){const o=t.host&&"object"==typeof t.host?t.host:n.createHost(),r=t.globalName||"ForgeBridge",i=t.readyEvent||"ui::ready",c=t.closeEvent||"ui::close",a=new Map;function l(e,n){const t=a.get(e);t&&0!==t.size&&t.forEach(t=>{try{t(n)}catch(n){console.error(`[ForgeWebUI] Bridge listener failed for ${e}.`,n)}})}function s(e,n={}){const t=String("object"==typeof e&&null!==e?e.event||"":e||""),o="object"==typeof e&&null!==e?e.data||{}:n;return!!t&&(l(t,o),l("*",{data:o,event:t}),!0)}function u(e){return!!Array.isArray(e)&&(e.forEach(e=>s(e)),!0)}const d={ping:()=>!0,receive:s,receiveMany:u,reset:()=>(a.clear(),!0)},f={close:(e={})=>o.send(c,e),emit:l,host:o,installCompatibility:n=>(n&&(e[n]=d),f),off(e,n){const t=a.get(e);return!!t&&(t.delete(n),0===t.size&&a.delete(e),!0)},on:(e,n)=>(function(e){return a.has(e)||a.set(e,new Set),a.get(e)}(e).add(n),()=>f.off(e,n)),ready:(e={loaded:!0})=>o.send(i,e),receive:s,receiveMany:u,request:(e,n={})=>o.send(e,n),send:(e,n={})=>o.send(e,n)};return e[r]=d,f}}(window),function(e){const n=e.ForgeWebUI=e.ForgeWebUI||{};n.createApp=function(e={}){const t=e.name||"app",o=e.root||"#app",r="function"==typeof e.setup?e.setup:()=>{};let i=!1;return{start:function(){if(i)return;i=!0;const e=()=>{const e=function(e){return e?"string"==typeof e?document.querySelector(e):e instanceof Element?e:null:null}(o);e?r({name:t,root:e,runtime:n}):console.error(`[ForgeWebUI] Root node not found for ${t}.`)};"loading"!==document.readyState?e():document.addEventListener("DOMContentLoaded",e,{once:!0})}}}}(window),function(e){const n=e.ForgeWebUI,t=e.SharedUI=e.SharedUI||{},{h:o,ensureScopedStyle:r}=n;function i({type:e}){return"minimize"===e?o("svg",{className:"ui-window-control-icon",viewBox:"0 0 16 16","aria-hidden":"true"},o("line",{x1:"3",y1:"8",x2:"13",y2:"8"})):"maximize"===e?o("svg",{className:"ui-window-control-icon",viewBox:"0 0 16 16","aria-hidden":"true"},o("rect",{x:"3.5",y:"3.5",width:"9",height:"9"})):o("svg",{className:"ui-window-control-icon",viewBox:"0 0 16 16","aria-hidden":"true"},o("line",{x1:"4",y1:"4",x2:"12",y2:"12"}),o("line",{x1:"12",y1:"4",x2:"4",y2:"12"}))}t.componentFns=t.componentFns||{},t.componentFns.WindowTitleBar=function({kicker:e="",title:n="",onClose:t=null,closeLabel:c="Close interface",minimizeLabel:a="Minimize unavailable",maximizeLabel:l="Maximize unavailable"}={}){return r("shared-window-titlebar","\n.ui-window-titlebar {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n min-height: var(--ui-titlebar-min-height, 3.5rem);\n padding: var(--ui-titlebar-padding, 0.65rem 0.8rem 0.7rem 0.95rem);\n background: var(\n --ui-titlebar-bg,\n linear-gradient(180deg, #12325b 0%, #0d2643 100%)\n );\n color: var(--ui-titlebar-text, #f4f8fd);\n border-bottom: 1px solid var(--ui-titlebar-border, rgb(33 73 120 / 1));\n box-shadow: var(--ui-titlebar-shadow, 0 8px 18px rgb(18 50 91 / 0.18));\n position: var(--ui-titlebar-position, relative);\n top: var(--ui-titlebar-top, auto);\n z-index: var(--ui-titlebar-z-index, 5);\n flex-shrink: 0;\n}\n\n.ui-window-titlebar-brand {\n display: flex;\n flex-direction: column;\n justify-content: center;\n gap: 0.1rem;\n min-width: 0;\n}\n\n.ui-window-titlebar-kicker {\n font-size: 0.64rem;\n font-weight: 700;\n line-height: 1;\n letter-spacing: 0.18em;\n text-transform: uppercase;\n color: var(--ui-titlebar-kicker-color, rgb(214 227 241 / 0.72));\n}\n\n.ui-window-titlebar-title {\n font-size: var(--ui-titlebar-title-size, 1rem);\n font-weight: 700;\n line-height: 1.1;\n letter-spacing: var(--ui-titlebar-title-spacing, -0.03em);\n color: inherit;\n}\n\n.ui-window-titlebar-controls {\n display: flex;\n align-items: center;\n gap: 0.12rem;\n}\n\n.ui-window-control-btn {\n min-width: 2rem;\n height: 2rem;\n margin: 0;\n padding: 0;\n border-radius: 0.38rem;\n border: 1px solid var(--ui-window-control-border, rgb(197 220 243 / 0.16));\n background: var(--ui-window-control-bg, rgb(255 255 255 / 0.04));\n color: var(--ui-window-control-text, rgb(237 244 251 / 0.88));\n line-height: 1;\n font-size: 0.82rem;\n font-weight: 700;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n box-shadow: none;\n transform: none;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n}\n\n.ui-window-control-btn + .ui-window-control-btn {\n margin-left: 0;\n}\n\n.ui-window-control-btn:hover {\n background: var(--ui-window-control-hover-bg, rgb(255 255 255 / 0.04));\n box-shadow: none;\n transform: none;\n}\n\n.ui-window-control-btn:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n.ui-window-control-btn.is-close {\n cursor: pointer;\n opacity: 1;\n background: var(--ui-window-control-close-bg, rgb(255 255 255 / 0.1));\n}\n\n.ui-window-control-btn.is-close:hover {\n background: var(\n --ui-window-control-close-hover-bg,\n rgb(185 67 67 / 0.9)\n );\n border-color: var(\n --ui-window-control-close-hover-border,\n rgb(255 222 222 / 0.45)\n );\n}\n\n.ui-window-control-icon {\n width: 0.78rem;\n height: 0.78rem;\n stroke: currentColor;\n fill: none;\n stroke-width: 1.5;\n stroke-linecap: round;\n stroke-linejoin: round;\n pointer-events: none;\n}\n\n@media (max-width: 960px) {\n .ui-window-titlebar {\n flex-direction: column;\n align-items: flex-start;\n }\n\n .ui-window-titlebar-controls {\n width: 100%;\n justify-content: flex-end;\n }\n}\n"),o("div",{className:"ui-window-titlebar"},o("div",{className:"ui-window-titlebar-brand"},e?o("span",{className:"ui-window-titlebar-kicker"},e):null,o("span",{className:"ui-window-titlebar-title"},n)),o("div",{className:"ui-window-titlebar-controls"},o("button",{type:"button",className:"ui-window-control-btn",disabled:!0,title:a,"aria-label":a},i({type:"minimize"})),o("button",{type:"button",className:"ui-window-control-btn",disabled:!0,title:l,"aria-label":l},i({type:"maximize"})),o("button",{type:"button",className:"ui-window-control-btn is-close",title:"Close","aria-label":c,onClick:"function"==typeof t?t:()=>{}},i({type:"close"}))))}}(window),function(e){(e.ForgeWebUI=e.ForgeWebUI||{}).version="0.1.0"}(window); \ No newline at end of file diff --git a/arma/client/addons/common/ui/src/app.js b/arma/client/addons/common/ui/src/app.js new file mode 100644 index 0000000..a3ad096 --- /dev/null +++ b/arma/client/addons/common/ui/src/app.js @@ -0,0 +1,60 @@ +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + function resolveRoot(root) { + if (!root) { + return null; + } + + if (typeof root === "string") { + return document.querySelector(root); + } + + return root instanceof Element ? root : null; + } + + function createApp(options = {}) { + const name = options.name || "app"; + const root = options.root || "#app"; + const setup = + typeof options.setup === "function" ? options.setup : () => {}; + let started = false; + + function start() { + if (started) { + return; + } + + started = true; + + const boot = () => { + const rootNode = resolveRoot(root); + if (!rootNode) { + console.error( + `[ForgeWebUI] Root node not found for ${name}.`, + ); + return; + } + + setup({ + name, + root: rootNode, + runtime: ForgeWebUI, + }); + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot, { + once: true, + }); + return; + } + + boot(); + } + + return { start }; + } + + ForgeWebUI.createApp = createApp; +})(window); diff --git a/arma/client/addons/common/ui/src/bridge.js b/arma/client/addons/common/ui/src/bridge.js new file mode 100644 index 0000000..877f393 --- /dev/null +++ b/arma/client/addons/common/ui/src/bridge.js @@ -0,0 +1,128 @@ +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + function createBridge(options = {}) { + const host = + options.host && typeof options.host === "object" + ? options.host + : ForgeWebUI.createHost(); + const globalName = options.globalName || "ForgeBridge"; + const readyEvent = options.readyEvent || "ui::ready"; + const closeEvent = options.closeEvent || "ui::close"; + const listeners = new Map(); + + function getListeners(eventName) { + if (!listeners.has(eventName)) { + listeners.set(eventName, new Set()); + } + + return listeners.get(eventName); + } + + function emit(eventName, payload) { + const eventListeners = listeners.get(eventName); + if (!eventListeners || eventListeners.size === 0) { + return; + } + + eventListeners.forEach((listener) => { + try { + listener(payload); + } catch (error) { + console.error( + `[ForgeWebUI] Bridge listener failed for ${eventName}.`, + error, + ); + } + }); + } + + function receive(eventOrPayload, data = {}) { + const eventName = + typeof eventOrPayload === "object" && eventOrPayload !== null + ? String(eventOrPayload.event || "") + : String(eventOrPayload || ""); + const payload = + typeof eventOrPayload === "object" && eventOrPayload !== null + ? eventOrPayload.data || {} + : data; + + if (!eventName) { + return false; + } + + emit(eventName, payload); + emit("*", { data: payload, event: eventName }); + return true; + } + + function receiveMany(events) { + if (!Array.isArray(events)) { + return false; + } + + events.forEach((payload) => receive(payload)); + return true; + } + + const globalBridge = { + ping() { + return true; + }, + receive, + receiveMany, + reset() { + listeners.clear(); + return true; + }, + }; + + const api = { + close(data = {}) { + return host.send(closeEvent, data); + }, + emit, + host, + installCompatibility(name) { + if (name) { + global[name] = globalBridge; + } + + return api; + }, + off(eventName, listener) { + const eventListeners = listeners.get(eventName); + if (!eventListeners) { + return false; + } + + eventListeners.delete(listener); + if (eventListeners.size === 0) { + listeners.delete(eventName); + } + + return true; + }, + on(eventName, listener) { + getListeners(eventName).add(listener); + return () => api.off(eventName, listener); + }, + ready(data = { loaded: true }) { + return host.send(readyEvent, data); + }, + receive, + receiveMany, + request(eventName, payload = {}) { + return host.send(eventName, payload); + }, + send(eventName, payload = {}) { + return host.send(eventName, payload); + }, + }; + + global[globalName] = globalBridge; + return api; + } + + ForgeWebUI.createBridge = createBridge; +})(window); diff --git a/arma/client/addons/common/ui/src/host.js b/arma/client/addons/common/ui/src/host.js new file mode 100644 index 0000000..8c199fd --- /dev/null +++ b/arma/client/addons/common/ui/src/host.js @@ -0,0 +1,68 @@ +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + function createHost() { + const api = global.A3API; + + return { + isArma: Boolean(api), + close(event = "ui::close", data = {}) { + return this.send(event, data); + }, + exec(statement) { + if ( + !api || + typeof api.Exec !== "function" || + typeof statement !== "string" + ) { + return false; + } + + api.Exec(statement); + return true; + }, + requestFile(path) { + if (api && typeof api.RequestFile === "function") { + return api.RequestFile(path); + } + + return fetch(path).then((response) => { + if (!response.ok) { + throw new Error(`Failed to load ${path}`); + } + + return response.text(); + }); + }, + requestTexture(path, size = 512) { + if (api && typeof api.RequestTexture === "function") { + return api.RequestTexture(path, size); + } + + return Promise.reject( + new Error("Texture requests are unavailable outside Arma."), + ); + }, + send(event, data = {}) { + if ( + !api || + typeof api.SendAlert !== "function" || + typeof event !== "string" || + event === "" + ) { + return false; + } + + api.SendAlert( + JSON.stringify({ + event, + data, + }), + ); + return true; + }, + }; + } + + ForgeWebUI.createHost = createHost; +})(window); diff --git a/arma/client/addons/common/ui/src/index.js b/arma/client/addons/common/ui/src/index.js new file mode 100644 index 0000000..ef53b2e --- /dev/null +++ b/arma/client/addons/common/ui/src/index.js @@ -0,0 +1,5 @@ +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + ForgeWebUI.version = "0.1.0"; +})(window); diff --git a/arma/client/addons/common/ui/src/runtime.js b/arma/client/addons/common/ui/src/runtime.js new file mode 100644 index 0000000..aa453b3 --- /dev/null +++ b/arma/client/addons/common/ui/src/runtime.js @@ -0,0 +1,428 @@ +(function (global) { + const ForgeWebUI = (global.ForgeWebUI = global.ForgeWebUI || {}); + + const SVG_NS = "http://www.w3.org/2000/svg"; + const SVG_TAGS = new Set([ + "svg", + "path", + "circle", + "rect", + "line", + "polyline", + "polygon", + "g", + "defs", + "use", + "text", + "tspan", + "clipPath", + "mask", + ]); + + const injectedStyles = new Set(); + const scheduledObservers = new Set(); + let activeObserver = null; + let batchDepth = 0; + let flushQueued = false; + + function queueFlush() { + if (flushQueued || batchDepth > 0) { + return; + } + + flushQueued = true; + queueMicrotask(() => { + flushQueued = false; + flushObservers(); + }); + } + + function flushObservers() { + while (scheduledObservers.size > 0) { + const queue = Array.from(scheduledObservers); + scheduledObservers.clear(); + queue.forEach((observer) => runObserver(observer)); + } + } + + function cleanupObserver(observer) { + if (typeof observer.cleanup === "function") { + try { + observer.cleanup(); + } catch (error) { + console.error("[ForgeWebUI] Observer cleanup failed.", error); + } + } + + observer.cleanup = null; + observer.dependencies.forEach((dependency) => { + dependency.delete(observer); + }); + observer.dependencies.clear(); + } + + function runObserver(observer) { + if (!observer || observer.disposed) { + return; + } + + cleanupObserver(observer); + + const previousObserver = activeObserver; + activeObserver = observer; + + try { + const cleanup = observer.fn(); + if (typeof cleanup === "function") { + observer.cleanup = cleanup; + } + } catch (error) { + console.error("[ForgeWebUI] Observer execution failed.", error); + } finally { + activeObserver = previousObserver; + } + } + + function scheduleObserver(observer) { + if (!observer || observer.disposed) { + return; + } + + scheduledObservers.add(observer); + queueFlush(); + } + + function trackDependency(dependency) { + if (!activeObserver) { + return; + } + + dependency.add(activeObserver); + activeObserver.dependencies.add(dependency); + } + + function createSignalValue(initialValue) { + let value = initialValue; + const subscribers = new Set(); + + function read() { + trackDependency(subscribers); + return value; + } + + read.peek = () => value; + read.set = (nextValue) => { + const resolvedValue = + typeof nextValue === "function" ? nextValue(value) : nextValue; + + if (Object.is(resolvedValue, value)) { + return value; + } + + value = resolvedValue; + subscribers.forEach((observer) => scheduleObserver(observer)); + return value; + }; + read.update = (updater) => read.set(updater); + read.subscribe = (listener) => + effect(() => { + listener(read()); + }); + + return read; + } + + function createSignal(initialValue) { + const signal = createSignalValue(initialValue); + return [signal, signal.set]; + } + + function computed(factory) { + const valueSignal = createSignalValue(undefined); + let initialized = false; + + effect(() => { + const nextValue = factory(); + if (!initialized || !Object.is(nextValue, valueSignal.peek())) { + initialized = true; + valueSignal.set(nextValue); + } + }); + + return valueSignal; + } + + function effect(fn) { + const observer = { + cleanup: null, + dependencies: new Set(), + disposed: false, + fn, + }; + + observer.dispose = () => { + if (observer.disposed) { + return; + } + + observer.disposed = true; + scheduledObservers.delete(observer); + cleanupObserver(observer); + }; + + runObserver(observer); + return observer.dispose; + } + + function batch(fn) { + batchDepth += 1; + + try { + return fn(); + } finally { + batchDepth = Math.max(0, batchDepth - 1); + if (batchDepth === 0) { + flushObservers(); + } + } + } + + function appendChild(node, child) { + if (child === null || child === undefined || child === false) { + return; + } + + if (Array.isArray(child)) { + child.forEach((entry) => appendChild(node, entry)); + return; + } + + if ( + typeof child === "string" || + typeof child === "number" || + typeof child === "bigint" + ) { + node.appendChild(document.createTextNode(String(child))); + return; + } + + if (child instanceof Node) { + node.appendChild(child); + } + } + + function fragment(...children) { + const node = document.createDocumentFragment(); + children.forEach((child) => appendChild(node, child)); + return node; + } + + function text(value) { + return document.createTextNode(String(value ?? "")); + } + + function applyProp(node, key, value, isSvg) { + if (key === "key") { + return; + } + + if (key === "ref" && typeof value === "function") { + value(node); + return; + } + + if (key === "className") { + if (isSvg) { + node.setAttribute("class", value || ""); + } else { + node.className = value || ""; + } + return; + } + + if (key === "style" && value && typeof value === "object") { + Object.assign(node.style, value); + return; + } + + if (key === "dataset" && value && typeof value === "object") { + Object.entries(value).forEach(([name, datasetValue]) => { + node.dataset[name] = datasetValue; + }); + return; + } + + if (key.startsWith("on") && typeof value === "function") { + node.addEventListener(key.slice(2).toLowerCase(), value); + return; + } + + if (key === "value" && "value" in node) { + node.value = value ?? ""; + return; + } + + if (key === "checked" && "checked" in node) { + node.checked = Boolean(value); + return; + } + + if (key === "selected" && "selected" in node) { + node.selected = Boolean(value); + return; + } + + if (typeof value === "boolean") { + if (value) { + node.setAttribute(key, ""); + } else { + node.removeAttribute(key); + } + return; + } + + if (value === null || value === undefined) { + node.removeAttribute(key); + return; + } + + node.setAttribute(key, value); + } + + function h(tag, props = {}, ...children) { + const isSvg = SVG_TAGS.has(tag); + const node = isSvg + ? document.createElementNS(SVG_NS, tag) + : document.createElement(tag); + + if (props && typeof props === "object") { + Object.entries(props).forEach(([key, value]) => { + applyProp(node, key, value, isSvg); + }); + } + + children.forEach((child) => appendChild(node, child)); + return node; + } + + function normalizeNode(node) { + if (node === null || node === undefined || node === false) { + return document.createDocumentFragment(); + } + + if (Array.isArray(node)) { + return fragment(...node); + } + + if ( + typeof node === "string" || + typeof node === "number" || + typeof node === "bigint" + ) { + return text(node); + } + + if (node instanceof Node) { + return node; + } + + return document.createDocumentFragment(); + } + + function captureScrollState(container) { + return Array.from( + container.querySelectorAll("[data-preserve-scroll-id]"), + ).map((node) => ({ + id: node.getAttribute("data-preserve-scroll-id"), + scrollLeft: node.scrollLeft, + scrollTop: node.scrollTop, + })); + } + + function restoreScrollState(container, scrollState) { + if (!Array.isArray(scrollState) || scrollState.length === 0) { + return; + } + + scrollState.forEach((entry) => { + if (!entry || !entry.id) { + return; + } + + const target = container.querySelector( + `[data-preserve-scroll-id="${entry.id}"]`, + ); + + if (!target) { + return; + } + + target.scrollTop = Number(entry.scrollTop || 0); + target.scrollLeft = Number(entry.scrollLeft || 0); + }); + } + + function mount(container, render, options = {}) { + const preserveScroll = options.preserveScroll !== false; + + const dispose = effect(() => { + const scrollState = preserveScroll + ? captureScrollState(container) + : []; + const nextNode = normalizeNode(render()); + + container.replaceChildren(nextNode); + + if (preserveScroll && scrollState.length > 0) { + requestAnimationFrame(() => { + restoreScrollState(container, scrollState); + }); + } + }); + + return { + container, + dispose, + rerender() { + container.replaceChildren(normalizeNode(render())); + }, + }; + } + + function render(component, container, options = {}) { + return mount(container, component, options); + } + + function unmount(mountHandle) { + if (!mountHandle || typeof mountHandle.dispose !== "function") { + return; + } + + mountHandle.dispose(); + } + + function ensureScopedStyle(id, cssText) { + if (!id || !cssText || injectedStyles.has(id)) { + return; + } + + const style = document.createElement("style"); + style.setAttribute("data-ui-style", id); + style.textContent = cssText; + document.head.appendChild(style); + injectedStyles.add(id); + } + + ForgeWebUI.batch = batch; + ForgeWebUI.computed = computed; + ForgeWebUI.createSignal = createSignal; + ForgeWebUI.effect = effect; + ForgeWebUI.ensureScopedStyle = ensureScopedStyle; + ForgeWebUI.fragment = fragment; + ForgeWebUI.h = h; + ForgeWebUI.mount = mount; + ForgeWebUI.render = render; + ForgeWebUI.signal = createSignalValue; + ForgeWebUI.text = text; + ForgeWebUI.unmount = unmount; +})(window); diff --git a/arma/client/addons/common/ui/src/siteLoader.js b/arma/client/addons/common/ui/src/siteLoader.js new file mode 100644 index 0000000..a5a6600 --- /dev/null +++ b/arma/client/addons/common/ui/src/siteLoader.js @@ -0,0 +1,126 @@ +(function (global) { + const ForgeSiteLoader = (global.ForgeSiteLoader = + global.ForgeSiteLoader || {}); + const commonAddonRoot = "forge\\forge_client\\addons\\common\\ui\\_site\\"; + const defaultBrowserCommonBase = "../../../common/ui/_site/"; + + function isArmaAvailable() { + return ( + typeof A3API !== "undefined" && + A3API && + typeof A3API.RequestFile === "function" + ); + } + + function isAbsoluteAddonPath(path) { + return typeof path === "string" && path.startsWith("forge\\"); + } + + function normalizeAddonRoot(addonName) { + return `forge\\forge_client\\addons\\${addonName}\\ui\\_site\\`; + } + + function normalizeBrowserPath(basePath, assetPath) { + const normalizedBase = String(basePath || "./").replace(/\\/g, "/"); + const normalizedAssetPath = String(assetPath || "").replace(/\\/g, "/"); + return `${normalizedBase}${normalizedAssetPath}`; + } + + function requestText({ addonRoot, browserBase, assetPath }) { + if (isArmaAvailable()) { + const resolvedPath = isAbsoluteAddonPath(assetPath) + ? assetPath + : addonRoot + String(assetPath || "").replace(/\//g, "\\"); + return A3API.RequestFile(resolvedPath); + } + + const browserPath = isAbsoluteAddonPath(assetPath) + ? assetPath + : normalizeBrowserPath(browserBase, assetPath); + + return fetch(browserPath).then((response) => { + if (!response.ok) { + throw new Error(`Failed to load ${browserPath}`); + } + + return response.text(); + }); + } + + function appendStyle(cssText) { + const style = document.createElement("style"); + style.textContent = cssText; + document.head.appendChild(style); + } + + function appendScript(jsText) { + const script = document.createElement("script"); + script.text = jsText; + document.head.appendChild(script); + } + + async function boot(config) { + const addonName = config && config.addonName ? config.addonName : ""; + + if (!addonName) { + throw new Error( + "ForgeSiteLoader requires a config.addonName value.", + ); + } + + const addonRoot = normalizeAddonRoot(addonName); + const browserAddonBase = config.browserAddonBase || "./"; + const browserCommonBase = + config.browserCommonBase || defaultBrowserCommonBase; + const styles = Array.isArray(config.styles) ? config.styles : []; + const commonScripts = Array.isArray(config.commonScripts) + ? config.commonScripts + : []; + const scripts = Array.isArray(config.scripts) ? config.scripts : []; + + const styleChunks = await Promise.all( + styles.map((assetPath) => + requestText({ + addonRoot, + browserBase: browserAddonBase, + assetPath, + }), + ), + ); + styleChunks.forEach(appendStyle); + + const commonScriptChunks = await Promise.all( + commonScripts.map((assetPath) => + requestText({ + addonRoot: commonAddonRoot, + browserBase: browserCommonBase, + assetPath, + }), + ), + ); + commonScriptChunks.forEach(appendScript); + + const scriptChunks = await Promise.all( + scripts.map((assetPath) => + requestText({ + addonRoot, + browserBase: browserAddonBase, + assetPath, + }), + ), + ); + scriptChunks.forEach(appendScript); + } + + ForgeSiteLoader.boot = boot; + + if (global.ForgeSiteConfig && global.ForgeSiteConfig.autoBoot !== false) { + boot(global.ForgeSiteConfig).catch((error) => { + const logLabel = + global.ForgeSiteConfig.logLabel || + global.ForgeSiteConfig.addonName || + "Forge UI"; + console.error(`[${logLabel}] Failed to load site assets.`, error); + }); + } +})(window); diff --git a/arma/client/addons/common/ui/src/windowTitleBar.js b/arma/client/addons/common/ui/src/windowTitleBar.js new file mode 100644 index 0000000..fe8ea58 --- /dev/null +++ b/arma/client/addons/common/ui/src/windowTitleBar.js @@ -0,0 +1,238 @@ +(function (global) { + const ForgeWebUI = global.ForgeWebUI; + const SharedUI = (global.SharedUI = global.SharedUI || {}); + const { h, ensureScopedStyle } = ForgeWebUI; + const titleBarCss = ` +.ui-window-titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: var(--ui-titlebar-min-height, 3.5rem); + padding: var(--ui-titlebar-padding, 0.65rem 0.8rem 0.7rem 0.95rem); + background: var( + --ui-titlebar-bg, + linear-gradient(180deg, #12325b 0%, #0d2643 100%) + ); + color: var(--ui-titlebar-text, #f4f8fd); + border-bottom: 1px solid var(--ui-titlebar-border, rgb(33 73 120 / 1)); + box-shadow: var(--ui-titlebar-shadow, 0 8px 18px rgb(18 50 91 / 0.18)); + position: var(--ui-titlebar-position, relative); + top: var(--ui-titlebar-top, auto); + z-index: var(--ui-titlebar-z-index, 5); + flex-shrink: 0; +} + +.ui-window-titlebar-brand { + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.1rem; + min-width: 0; +} + +.ui-window-titlebar-kicker { + font-size: 0.64rem; + font-weight: 700; + line-height: 1; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ui-titlebar-kicker-color, rgb(214 227 241 / 0.72)); +} + +.ui-window-titlebar-title { + font-size: var(--ui-titlebar-title-size, 1rem); + font-weight: 700; + line-height: 1.1; + letter-spacing: var(--ui-titlebar-title-spacing, -0.03em); + color: inherit; +} + +.ui-window-titlebar-controls { + display: flex; + align-items: center; + gap: 0.12rem; +} + +.ui-window-control-btn { + min-width: 2rem; + height: 2rem; + margin: 0; + padding: 0; + border-radius: 0.38rem; + border: 1px solid var(--ui-window-control-border, rgb(197 220 243 / 0.16)); + background: var(--ui-window-control-bg, rgb(255 255 255 / 0.04)); + color: var(--ui-window-control-text, rgb(237 244 251 / 0.88)); + line-height: 1; + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + box-shadow: none; + transform: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ui-window-control-btn + .ui-window-control-btn { + margin-left: 0; +} + +.ui-window-control-btn:hover { + background: var(--ui-window-control-hover-bg, rgb(255 255 255 / 0.04)); + box-shadow: none; + transform: none; +} + +.ui-window-control-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.ui-window-control-btn.is-close { + cursor: pointer; + opacity: 1; + background: var(--ui-window-control-close-bg, rgb(255 255 255 / 0.1)); +} + +.ui-window-control-btn.is-close:hover { + background: var( + --ui-window-control-close-hover-bg, + rgb(185 67 67 / 0.9) + ); + border-color: var( + --ui-window-control-close-hover-border, + rgb(255 222 222 / 0.45) + ); +} + +.ui-window-control-icon { + width: 0.78rem; + height: 0.78rem; + stroke: currentColor; + fill: none; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: none; +} + +@media (max-width: 960px) { + .ui-window-titlebar { + flex-direction: column; + align-items: flex-start; + } + + .ui-window-titlebar-controls { + width: 100%; + justify-content: flex-end; + } +} +`; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + function WindowControlIcon({ type }) { + if (type === "minimize") { + return h( + "svg", + { + className: "ui-window-control-icon", + viewBox: "0 0 16 16", + "aria-hidden": "true", + }, + h("line", { x1: "3", y1: "8", x2: "13", y2: "8" }), + ); + } + + if (type === "maximize") { + return h( + "svg", + { + className: "ui-window-control-icon", + viewBox: "0 0 16 16", + "aria-hidden": "true", + }, + h("rect", { x: "3.5", y: "3.5", width: "9", height: "9" }), + ); + } + + return h( + "svg", + { + className: "ui-window-control-icon", + viewBox: "0 0 16 16", + "aria-hidden": "true", + }, + h("line", { x1: "4", y1: "4", x2: "12", y2: "12" }), + h("line", { x1: "12", y1: "4", x2: "4", y2: "12" }), + ); + } + + SharedUI.componentFns.WindowTitleBar = function WindowTitleBar({ + kicker = "", + title = "", + onClose = null, + closeLabel = "Close interface", + minimizeLabel = "Minimize unavailable", + maximizeLabel = "Maximize unavailable", + } = {}) { + ensureScopedStyle("shared-window-titlebar", titleBarCss); + + return h( + "div", + { className: "ui-window-titlebar" }, + h( + "div", + { className: "ui-window-titlebar-brand" }, + kicker + ? h( + "span", + { className: "ui-window-titlebar-kicker" }, + kicker, + ) + : null, + h("span", { className: "ui-window-titlebar-title" }, title), + ), + h( + "div", + { className: "ui-window-titlebar-controls" }, + h( + "button", + { + type: "button", + className: "ui-window-control-btn", + disabled: true, + title: minimizeLabel, + "aria-label": minimizeLabel, + }, + WindowControlIcon({ type: "minimize" }), + ), + h( + "button", + { + type: "button", + className: "ui-window-control-btn", + disabled: true, + title: maximizeLabel, + "aria-label": maximizeLabel, + }, + WindowControlIcon({ type: "maximize" }), + ), + h( + "button", + { + type: "button", + className: "ui-window-control-btn is-close", + title: "Close", + "aria-label": closeLabel, + onClick: + typeof onClose === "function" ? onClose : () => {}, + }, + WindowControlIcon({ type: "close" }), + ), + ), + ); + }; +})(window); diff --git a/arma/client/addons/garage/README.md b/arma/client/addons/garage/README.md index 5378813..0442fa7 100644 --- a/arma/client/addons/garage/README.md +++ b/arma/client/addons/garage/README.md @@ -1,4 +1,3 @@ -forge_client_garage -=================== +# forge_client_garage Description for this addon diff --git a/arma/client/addons/garage/XEH_PREP.hpp b/arma/client/addons/garage/XEH_PREP.hpp index 98bd123..9db23f1 100644 --- a/arma/client/addons/garage/XEH_PREP.hpp +++ b/arma/client/addons/garage/XEH_PREP.hpp @@ -1,3 +1,8 @@ -PREP(initGarageClass); +PREP(handleUIEvents); +PREP(initCatalogService); +PREP(initClass); +PREP(initSessionService); +PREP(initUIBridge); PREP(initVGClass); +PREP(openUI); PREP(openVG); diff --git a/arma/client/addons/garage/XEH_postInitClient.sqf b/arma/client/addons/garage/XEH_postInitClient.sqf index d678a9b..7d53dc5 100644 --- a/arma/client/addons/garage/XEH_postInitClient.sqf +++ b/arma/client/addons/garage/XEH_postInitClient.sqf @@ -1,7 +1,10 @@ #include "script_component.hpp" -if (isNil QGVAR(GarageClass)) then { [] call FUNC(initGarageClass); }; -if (isNil QGVAR(VGarageClass)) then { [] call FUNC(initVGClass); }; +if (isNil QGVAR(GarageCatalogService)) then { call FUNC(initCatalogService); }; +if (isNil QGVAR(GarageClass)) then { call FUNC(initClass); }; +if (isNil QGVAR(GarageSessionService)) then { call FUNC(initSessionService); }; +if (isNil QGVAR(GarageUIBridge)) then { call FUNC(initUIBridge); }; +if (isNil QGVAR(VGClass)) then { call FUNC(initVGClass); }; [QGVAR(initGarage), { GVAR(GarageClass) call ["init", []]; @@ -10,29 +13,43 @@ if (isNil QGVAR(VGarageClass)) then { [] call FUNC(initVGClass); }; [QGVAR(responseInitGarage), { params [["_data", createHashMap, [createHashMap]]]; - GVAR(GarageClass) call ["sync", [_data, true]]; + GVAR(GarageClass) call ["sync", [_data]]; + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["refreshGarage", []]; + }; }] call CFUNC(addEventHandler); [QGVAR(responseSyncGarage), { - params [["_data", createHashMap, [createHashMap, []]], ["_jip", false, [false]]]; + params [["_data", createHashMap, [createHashMap, []]]]; - GVAR(GarageClass) call ["sync", [_data, _jip]]; + GVAR(GarageClass) call ["sync", [_data]]; + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["refreshGarage", []]; + }; +}] call CFUNC(addEventHandler); + +[QGVAR(responseGarageAction), { + params [["_payload", createHashMap, [createHashMap]]]; + + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["handleActionResponse", [_payload]]; + }; }] call CFUNC(addEventHandler); [QGVAR(initVG), { - GVAR(VGarageClass) call ["init", []]; + GVAR(VGClass) call ["init", []]; }] call CFUNC(addEventHandler); [QGVAR(responseInitVG), { params [["_data", createHashMap, [createHashMap]]]; - GVAR(VGarageClass) call ["sync", [_data, true]]; + GVAR(VGClass) call ["sync", [_data]]; }] call CFUNC(addEventHandler); [QGVAR(responseSyncVG), { - params [["_data", createHashMap, [createHashMap, []]], ["_jip", false, [false]]]; + params [["_data", createHashMap, [createHashMap, []]]]; - GVAR(VGarageClass) call ["sync", [_data, _jip]]; + GVAR(VGClass) call ["sync", [_data]]; }] call CFUNC(addEventHandler); [{ diff --git a/arma/client/addons/garage/XEH_preStart.sqf b/arma/client/addons/garage/XEH_preStart.sqf index 0228885..a51262a 100644 --- a/arma/client/addons/garage/XEH_preStart.sqf +++ b/arma/client/addons/garage/XEH_preStart.sqf @@ -1,3 +1,2 @@ #include "script_component.hpp" - #include "XEH_PREP.hpp" diff --git a/arma/client/addons/garage/config.cpp b/arma/client/addons/garage/config.cpp index 595b77a..07836a5 100644 --- a/arma/client/addons/garage/config.cpp +++ b/arma/client/addons/garage/config.cpp @@ -8,6 +8,7 @@ class CfgPatches { name = COMPONENT_NAME; requiredVersion = REQUIRED_VERSION; requiredAddons[] = { + "forge_client_common", "forge_client_main" }; units[] = {}; @@ -17,3 +18,5 @@ class CfgPatches { }; #include "CfgEventHandlers.hpp" +#include "ui\RscCommon.hpp" +#include "ui\RscGarage.hpp" diff --git a/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf b/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf index c30fc0b..ae6ee6e 100644 --- a/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/garage/functions/fnc_handleUIEvents.sqf @@ -1,17 +1,66 @@ #include "..\script_component.hpp" /* + * File: fnc_handleUIEvents.sqf * Author: IDSolutions + * Date: 2025-12-16 + * Last Update: 2026-01-30 + * Public: No + * + * Description: * Handles the UI events. * * Arguments: - * None + * 0: [CONTROL] - The control that triggered the event + * 1: [BOOL] - Whether the event is from a confirm dialog + * 2: [STRING] - The message containing the event data * * Return Value: - * None + * UI events handled [BOOL] * * Example: - * [] call forge_client_garage_fnc_handleUIEvents; - * - * Public: No + * call forge_client_garage_fnc_handleUIEvents; */ + +params ["_control", "_isConfirmDialog", "_message"]; + +private _alert = fromJSON _message; +private _event = _alert get "event"; +private _data = _alert get "data"; + +diag_log format ["[FORGE:Client:Garage] Handling UI event: %1 with data: %2", _event, _data]; + +switch (_event) do { + case "garage::close": { + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["handleClose", []]; + }; + + closeDialog 1; + }; + case "garage::ready": { + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["handleReady", [_control, _data]]; + }; + }; + case "garage::vehicle::retrieve::request": { + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["handleRetrieveRequest", [_data]]; + }; + }; + case "garage::vehicle::store::request": { + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["handleStoreRequest", [_data]]; + }; + }; + case "garage::refresh": { + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["refreshGarage", []]; + }; + }; + default { + hint format ["Unhandled garage UI event: %1", _event]; + }; +}; + +true; diff --git a/arma/client/addons/garage/functions/fnc_initCatalogService.sqf b/arma/client/addons/garage/functions/fnc_initCatalogService.sqf new file mode 100644 index 0000000..748b5e4 --- /dev/null +++ b/arma/client/addons/garage/functions/fnc_initCatalogService.sqf @@ -0,0 +1,160 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initCatalogService.sqf + * Author: IDSolutions + * Date: 2026-03-14 + * Public: No + * + * Description: + * Initializes the garage catalog service for vehicle metadata and UI-friendly shaping. + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(GarageCatalogServiceBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "GarageCatalogServiceBaseClass"], + ["resolveCategory", compileFinal { + params [["_className", "", [""]]]; + + if (_className isEqualTo "") exitWith { "other" }; + + switch (true) do { + case (_className isKindOf ["Car", configFile >> "CfgVehicles"]): { "car" }; + case (_className isKindOf ["Tank", configFile >> "CfgVehicles"]): { "armor" }; + case (_className isKindOf ["Helicopter", configFile >> "CfgVehicles"]): { "air" }; + case (_className isKindOf ["Plane", configFile >> "CfgVehicles"]): { "air" }; + case (_className isKindOf ["Ship", configFile >> "CfgVehicles"]): { "naval" }; + default { "other" }; + } + }], + ["resolveDisplayName", compileFinal { + params [["_className", "", [""]]]; + + private _displayName = getText (configFile >> "CfgVehicles" >> _className >> "displayName"); + if (_displayName isEqualTo "") then { + _displayName = _className; + }; + + _displayName + }], + ["resolvePicture", compileFinal { + params [["_className", "", [""]]]; + + private _picture = getText (configFile >> "CfgVehicles" >> _className >> "editorPreview"); + if (_picture isEqualTo "") then { + _picture = getText (configFile >> "CfgVehicles" >> _className >> "picture"); + }; + + _picture + }], + ["buildHitPointRows", compileFinal { + params [["_hitPoints", createHashMap, [createHashMap]]]; + + private _rows = []; + private _names = _hitPoints getOrDefault ["names", []]; + private _selections = _hitPoints getOrDefault ["selections", []]; + private _values = _hitPoints getOrDefault ["values", []]; + private _count = count _names; + + for "_index" from 0 to (_count - 1) do { + private _rowName = _names param [_index, ""]; + _rows pushBack (createHashMapFromArray [ + ["name", _rowName], + ["selection", _selections param [_index, ""]], + ["value", _values param [_index, 0]] + ]); + }; + + _rows + }], + ["resolveHealth", compileFinal { + params [["_damage", 0, [0]], ["_hitPointRows", [], [[]]]]; + + private _worstHitPoint = 0; + { + private _value = _x getOrDefault ["value", 0]; + if (_value > _worstHitPoint) then { + _worstHitPoint = _value; + }; + } forEach _hitPointRows; + + 1 - ((_damage max _worstHitPoint) min 1) + }], + ["buildStoredVehicle", compileFinal { + params [["_plate", "", [""]], ["_vehicleData", createHashMap, [createHashMap]]]; + + private _className = _vehicleData getOrDefault ["classname", ""]; + private _damage = _vehicleData getOrDefault ["damage", 0]; + private _fuel = _vehicleData getOrDefault ["fuel", 0]; + private _hitPoints = _vehicleData getOrDefault ["hit_points", createHashMap]; + private _hitPointRows = _self call ["buildHitPointRows", [_hitPoints]]; + + createHashMapFromArray [ + ["entryKind", "stored"], + ["plate", _plate], + ["classname", _className], + ["displayName", _self call ["resolveDisplayName", [_className]]], + ["picture", _self call ["resolvePicture", [_className]]], + ["category", _self call ["resolveCategory", [_className]]], + ["damage", _damage], + ["fuel", _fuel], + ["health", _self call ["resolveHealth", [_damage, _hitPointRows]]], + ["hitPoints", _hitPointRows] + ] + }], + ["buildNearbyVehicle", compileFinal { + params [ + ["_vehicle", objNull, [objNull]], + ["_origin", [], [[]]] + ]; + + if (isNull _vehicle) exitWith { createHashMap }; + + private _className = typeOf _vehicle; + private _rawHitPoints = getAllHitPointsDamage _vehicle; + private _hitPointRows = []; + if (_rawHitPoints isEqualType [] && { count _rawHitPoints >= 3 }) then { + private _names = _rawHitPoints param [0, []]; + private _selections = _rawHitPoints param [1, []]; + private _values = _rawHitPoints param [2, []]; + private _count = count _names; + + for "_index" from 0 to (_count - 1) do { + _hitPointRows pushBack (createHashMapFromArray [ + ["name", _names param [_index, ""]], + ["selection", _selections param [_index, ""]], + ["value", _values param [_index, 0]] + ]); + }; + }; + + private _damage = damage _vehicle; + private _distance = if (_origin isEqualType [] && { count _origin >= 2 }) then { + _vehicle distance2D _origin + } else { + _vehicle distance2D player + }; + private _ownerUid = _vehicle getVariable ["forge_garage_owner_uid", ""]; + private _plate = _vehicle getVariable ["forge_garage_plate", ""]; + + createHashMapFromArray [ + ["entryKind", "nearby"], + ["netId", netId _vehicle], + ["plate", _plate], + ["classname", _className], + ["displayName", _self call ["resolveDisplayName", [_className]]], + ["picture", _self call ["resolvePicture", [_className]]], + ["category", _self call ["resolveCategory", [_className]]], + ["damage", _damage], + ["fuel", fuel _vehicle], + ["health", _self call ["resolveHealth", [_damage, _hitPointRows]]], + ["hitPoints", _hitPointRows], + ["distance", _distance], + ["ownerUid", _ownerUid], + ["isEmpty", crew _vehicle isEqualTo []] + ] + }] +]; + +GVAR(GarageCatalogService) = createHashMapObject [GVAR(GarageCatalogServiceBaseClass)]; +GVAR(GarageCatalogService) diff --git a/arma/client/addons/garage/functions/fnc_initGarageClass.sqf b/arma/client/addons/garage/functions/fnc_initClass.sqf similarity index 55% rename from arma/client/addons/garage/functions/fnc_initGarageClass.sqf rename to arma/client/addons/garage/functions/fnc_initClass.sqf index 03e036a..841550b 100644 --- a/arma/client/addons/garage/functions/fnc_initGarageClass.sqf +++ b/arma/client/addons/garage/functions/fnc_initClass.sqf @@ -1,31 +1,36 @@ #include "..\script_component.hpp" /* + * File: fnc_initClass.sqf * Author: IDSolutions - * Initializes the garage class. + * Date: 2025-12-17 + * Last Update: 2026-02-13 + * Public: No + * + * Description: + * Initializes the Garage class for managing player vehicles. + * Provides methods for syncing, saving, and applying vehicles to the player's garage. * * Arguments: * None * * Return Value: - * None + * Garage class object [HASHMAP OBJECT] * * Example: - * [] call forge_client_garage_fnc_initGarageClass; - * - * Public: No + * call forge_client_garage_fnc_initClass */ #pragma hemtt ignore_variables ["_self"] -GVAR(GarageClass) = createHashMapObject [[ - ["#type", "IGarageClass"], - ["#create", { +GVAR(GarageBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "GarageBaseClass"], + ["#create", compileFinal { _self set ["uid", (getPlayerUID player)]; _self set ["garage", createHashMap]; _self set ["isLoaded", false]; _self set ["lastSave", time]; }], - ["init", { + ["init", compileFinal { private _uid = _self get "uid"; private _garage = _self get "garage"; @@ -34,40 +39,34 @@ GVAR(GarageClass) = createHashMapObject [[ systemChat format ["Garage loaded for %1", (name player)]; diag_log "[FORGE:Client:Garage] Garage Class Initialized!"; }], - ["save", { - params [["_sync", false, [false]]]; - + ["save", compileFinal { private _uid = _self get "uid"; - [SRPC(garage,requestSaveGarage), [_uid, _sync]] call CFUNC(serverEvent); + [SRPC(garage,requestSaveGarage), [_uid]] call CFUNC(serverEvent); _self set ["lastSave", time]; }], - ["sync", { - params [["_data", createHashMap, [createHashMap]], ["_sync", false, [false]]]; + ["sync", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; - private _garage = _self get "garage"; private _isLoaded = _self get "isLoaded"; + private _garage = createHashMap; - if (_data isEqualTo createHashMap) exitWith { - diag_log "[FORGE:Client:Garage] Empty data received for sync, skipping."; - }; - - { - _garage set [_x, _y]; - } forEach _data; - + { _garage set [_x, _y]; } forEach _data; _self set ["garage", _garage]; if !(_isLoaded) then { _self set ["isLoaded", true]; }; diag_log "[FORGE:Client:Garage] Sync completed"; }], - ["get", { + ["getGarageState", compileFinal { + _self getOrDefault ["garage", createHashMap] + }], + ["get", compileFinal { params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; private _garage = _self get "garage"; _garage getOrDefault [_key, _default]; }] -]]; +]; -SETVAR(player,FORGE_GarageClass,GVAR(GarageClass)); +GVAR(GarageClass) = createHashMapObject [GVAR(GarageBaseClass)]; GVAR(GarageClass) diff --git a/arma/client/addons/garage/functions/fnc_initSessionService.sqf b/arma/client/addons/garage/functions/fnc_initSessionService.sqf new file mode 100644 index 0000000..7fe871b --- /dev/null +++ b/arma/client/addons/garage/functions/fnc_initSessionService.sqf @@ -0,0 +1,298 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initSessionService.sqf + * Author: IDSolutions + * Date: 2026-03-14 + * Public: No + * + * Description: + * Initializes the typed garage session service responsible for resolving the + * active garage context and building the browser hydrate payload. + */ + +#pragma hemtt ignore_variables ["_self"] + +GVAR(GarageSessionServiceBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "GarageSessionServiceBaseClass"], + ["#create", compileFinal { + _self set ["lastContext", createHashMap]; + }], + ["#delete", compileFinal { + _self set ["lastContext", createHashMap]; + }], + ["createDefaultContext", compileFinal { + createHashMapFromArray [ + ["name", "Vehicle Garage"], + ["anchorPosition", getPosATL player], + ["sourceObject", objNull], + ["spawnHeading", getDir player], + ["spawnPosition", player getPos [8, getDir player]], + ["spawnRadius", 6], + ["nearbyRadius", 30] + ] + }], + ["scanEntryValues", compileFinal { + params [ + ["_values", [], [[]]], + ["_state", createHashMap, [createHashMap]] + ]; + + { + if (_x isEqualType "" && { (_state getOrDefault ["name", "Vehicle Garage"]) isEqualTo "Vehicle Garage" }) then { + _state set ["name", _x]; + }; + + if (_x isEqualType "") then { + private _resolvedObject = _state getOrDefault ["sourceObject", objNull]; + if (isNull _resolvedObject) then { + private _namedObject = missionNamespace getVariable [_x, objNull]; + if (!isNull _namedObject) then { + _state set ["sourceObject", _namedObject]; + }; + }; + + if ((_state getOrDefault ["anchorPosition", []]) isEqualTo [] && { _x in allMapMarkers }) then { + _state set ["anchorPosition", markerPos _x]; + }; + + continue; + }; + + if (_x isEqualType objNull && { isNull (_state getOrDefault ["sourceObject", objNull]) }) then { + _state set ["sourceObject", _x]; + if ((_state getOrDefault ["anchorPosition", []]) isEqualTo []) then { + _state set ["anchorPosition", getPosATL _x]; + }; + continue; + }; + + if (_x isEqualType 0 && { (_state getOrDefault ["spawnHeading", -1]) < 0 }) then { + _state set ["spawnHeading", _x]; + continue; + }; + + if (_x isEqualType [] && { count _x > 0 }) then { + if ( + { _x isEqualType 0 } count _x >= 2 && + { + ((_state getOrDefault ["offset", []]) isEqualTo []) || + ((_state getOrDefault ["anchorPosition", []]) isEqualTo []) + } + ) then { + if ((_state getOrDefault ["anchorPosition", []]) isEqualTo []) then { + _state set ["anchorPosition", _x]; + } else { + _state set ["offset", _x]; + }; + continue; + }; + + _self call ["scanEntryValues", [_x, _state]]; + }; + } forEach _values; + + _state + }], + ["resolveEntry", compileFinal { + params [["_entry", [], [[]]]]; + + private _state = createHashMapFromArray [ + ["name", "Vehicle Garage"], + ["anchorPosition", []], + ["sourceObject", objNull], + ["offset", []], + ["spawnHeading", -1] + ]; + + _self call ["scanEntryValues", [_entry, _state]]; + + private _anchorPosition = _state getOrDefault ["anchorPosition", []]; + private _offset = _state getOrDefault ["offset", []]; + private _spawnPosition = if (_anchorPosition isEqualTo []) then { + [] + } else { + if (_offset isEqualTo []) then { + _anchorPosition + } else { + _anchorPosition vectorAdd _offset + } + }; + + createHashMapFromArray [ + ["name", _state getOrDefault ["name", "Vehicle Garage"]], + ["anchorPosition", _anchorPosition], + ["sourceObject", _state getOrDefault ["sourceObject", objNull]], + ["spawnHeading", _state getOrDefault ["spawnHeading", -1]], + ["spawnPosition", _spawnPosition] + ] + }], + ["resolveContext", compileFinal { + private _context = _self call ["createDefaultContext", []]; + private _locations = (missionConfigFile >> "FORGE_CfgGarages" >> "locations") call BFUNC(getCfgData); + if !(_locations isEqualType []) exitWith { + _self set ["lastContext", _context]; + _context + }; + + private _nearestEntry = []; + private _nearestDistance = 1e10; + + { + private _entry = _self call ["resolveEntry", [_x]]; + private _anchorPosition = _entry getOrDefault ["anchorPosition", []]; + if (_anchorPosition isEqualTo []) then { + continue; + }; + + private _distance = player distance2D _anchorPosition; + if (_distance < _nearestDistance) then { + _nearestDistance = _distance; + _nearestEntry = _entry; + }; + } forEach _locations; + + if (_nearestEntry isEqualTo []) exitWith { + _self set ["lastContext", _context]; + _context + }; + + private _anchorPosition = _nearestEntry getOrDefault ["anchorPosition", []]; + private _garageObject = _nearestEntry getOrDefault ["sourceObject", objNull]; + private _garageName = _nearestEntry getOrDefault ["name", "Vehicle Garage"]; + private _spawnHeading = _nearestEntry getOrDefault ["spawnHeading", getDir player]; + if (_spawnHeading < 0) then { + _spawnHeading = if (!isNull _garageObject) then { getDir _garageObject } else { getDir player }; + }; + + private _spawnPosition = _nearestEntry getOrDefault ["spawnPosition", []]; + if (_spawnPosition isEqualTo []) then { + _spawnPosition = if (_anchorPosition isEqualTo []) then { + player getPos [8, _spawnHeading] + } else { + _anchorPosition + }; + }; + + _context set ["name", _garageName]; + _context set ["anchorPosition", _anchorPosition]; + _context set ["sourceObject", _garageObject]; + _context set ["spawnHeading", _spawnHeading]; + _context set ["spawnPosition", _spawnPosition]; + + _self set ["lastContext", _context]; + _context + }], + ["getContext", compileFinal { + _self call ["resolveContext", []] + }], + ["buildPayload", compileFinal { + private _context = _self call ["getContext", []]; + private _garageMap = if (isNil QGVAR(GarageClass)) then { + createHashMap + } else { + GVAR(GarageClass) call ["getGarageState", []] + }; + + private _anchorPosition = _context getOrDefault ["anchorPosition", []]; + private _spawnPosition = _context getOrDefault ["spawnPosition", getPosATL player]; + private _spawnRadius = _context getOrDefault ["spawnRadius", 6]; + private _nearbyRadius = _context getOrDefault ["nearbyRadius", 30]; + private _nearbyOrigin = [_anchorPosition, _spawnPosition] select (_anchorPosition isEqualTo []); + + private _storedVehicles = []; + private _nearbyVehicles = []; + private _nearbyEntities = []; + private _candidateVehicles = []; + + { + _candidateVehicles pushBackUnique _x; + } forEach (_nearbyOrigin nearEntities [["Car", "Tank", "Air", "Ship"], _nearbyRadius]); + { + _candidateVehicles pushBackUnique _x; + } forEach ((getPosATL player) nearEntities [["Car", "Tank", "Air", "Ship"], _nearbyRadius]); + { + _candidateVehicles pushBackUnique _x; + } forEach (nearestObjects [_nearbyOrigin, ["AllVehicles"], _nearbyRadius]); + { + _candidateVehicles pushBackUnique _x; + } forEach (nearestObjects [getPosATL player, ["AllVehicles"], _nearbyRadius]); + + { + if (isNull _x) then { + continue; + }; + + if (_x isKindOf "CAManBase") then { + continue; + }; + + if !( + _x isKindOf "Car" || + _x isKindOf "Tank" || + _x isKindOf "Air" || + _x isKindOf "Ship" + ) then { + continue; + }; + + _nearbyEntities pushBackUnique _x; + } forEach _candidateVehicles; + + { + _storedVehicles pushBack ( + GVAR(GarageCatalogService) call ["buildStoredVehicle", [_x, _y]] + ); + } forEach _garageMap; + + private _storedVehiclePairs = _storedVehicles apply { + [toLowerANSI (_x getOrDefault ["displayName", ""]), _x] + }; + _storedVehiclePairs sort true; + _storedVehicles = _storedVehiclePairs apply { _x param [1, createHashMap] }; + + { + if (isNull _x) then { + continue; + }; + + private _builtVehicle = GVAR(GarageCatalogService) call ["buildNearbyVehicle", [_x, _nearbyOrigin]]; + if (_builtVehicle isEqualTo createHashMap) then { + continue; + }; + + _nearbyVehicles pushBack _builtVehicle; + } forEach _nearbyEntities; + + private _nearbyVehiclePairs = _nearbyVehicles apply { + [_x getOrDefault ["distance", 0], _x] + }; + _nearbyVehiclePairs sort true; + _nearbyVehicles = _nearbyVehiclePairs apply { _x param [1, createHashMap] }; + + private _spawnBlocked = ( + (_spawnPosition nearEntities [["Car", "Tank", "Air", "Ship"], _spawnRadius]) + + (nearestObjects [_spawnPosition, ["Car", "Tank", "Air", "Ship"], _spawnRadius]) + ) isNotEqualTo []; + + createHashMapFromArray [ + ["session", createHashMapFromArray [ + ["garageName", _context getOrDefault ["name", "Vehicle Garage"]], + ["capacityUsed", count _storedVehicles], + ["capacityMax", 5], + ["nearbyCount", count _nearbyVehicles], + ["spawnBlocked", _spawnBlocked], + ["spawnStatus", ["Ready", "Blocked"] select _spawnBlocked] + ]], + ["garage", createHashMapFromArray [ + ["vehicles", _storedVehicles] + ]], + ["nearby", createHashMapFromArray [ + ["vehicles", _nearbyVehicles] + ]] + ] + }] +]; + +GVAR(GarageSessionService) = createHashMapObject [GVAR(GarageSessionServiceBaseClass)]; +GVAR(GarageSessionService) diff --git a/arma/client/addons/garage/functions/fnc_initUIBridge.sqf b/arma/client/addons/garage/functions/fnc_initUIBridge.sqf new file mode 100644 index 0000000..ec15d50 --- /dev/null +++ b/arma/client/addons/garage/functions/fnc_initUIBridge.sqf @@ -0,0 +1,205 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initUIBridge.sqf + * Author: IDSolutions + * Date: 2026-03-14 + * Public: No + * + * Description: + * Initializes the garage UI bridge for browser control state and retrieve/store actions. + */ + +#pragma hemtt ignore_variables ["_self"] +private _webUIDeclarations = call EFUNC(common,initWebUIBridge); +private _webUIBridgeDeclaration = _webUIDeclarations get "bridgeDeclaration"; + +GVAR(GarageUIBridgeBaseClass) = compileFinal createHashMapFromArray [ + ["#base", _webUIBridgeDeclaration], + ["#type", "GarageUIBridgeBaseClass"], + ["#create", compileFinal { + _self set ["pendingStoreVehicle", objNull]; + _self set ["pendingRetrieve", createHashMap]; + }], + ["getActiveBrowserControl", compileFinal { + private _display = uiNamespace getVariable ["RscGarage", displayNull]; + if (isNull _display) exitWith { + _self call ["setActiveBrowserControl", [controlNull]]; + controlNull + }; + + private _control = _display displayCtrl 1006; + _self call ["setActiveBrowserControl", [_control]]; + _control + }], + ["handleReady", compileFinal { + params [["_control", controlNull, [controlNull]], ["_data", createHashMap, [createHashMap]]]; + + private _screen = _self call ["getScreen", []]; + _screen call ["setControl", [_control]]; + _screen call ["markReady", [true]]; + + _self call ["flushPendingEvents", []]; + _self call ["sendEvent", ["garage::hydrate", GVAR(GarageSessionService) call ["buildPayload", []], _control]]; + }], + ["refreshGarage", compileFinal { + private _control = _self call ["getActiveBrowserControl", []]; + if (isNull _control) exitWith { false }; + + _self call ["sendEvent", ["garage::sync", GVAR(GarageSessionService) call ["buildPayload", []], _control]] + }], + ["handleRetrieveRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _plate = _data getOrDefault ["plate", ""]; + if (_plate isEqualTo "") exitWith { + _self call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [ + ["message", "Select a stored vehicle to retrieve."] + ]]]; + }; + + private _garageMap = if (isNil QGVAR(GarageClass)) then { + createHashMap + } else { + GVAR(GarageClass) call ["getGarageState", []] + }; + private _vehicleData = _garageMap getOrDefault [_plate, createHashMap]; + if (_vehicleData isEqualTo createHashMap) exitWith { + _self call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [ + ["message", "Stored vehicle record could not be found."] + ]]]; + }; + + private _context = GVAR(GarageSessionService) call ["getContext", []]; + private _spawnPosition = _context getOrDefault ["spawnPosition", getPosATL player]; + private _spawnHeading = _context getOrDefault ["spawnHeading", getDir player]; + private _spawnRadius = _context getOrDefault ["spawnRadius", 6]; + private _blockingVehicles = []; + { + _blockingVehicles pushBackUnique _x; + } forEach (_spawnPosition nearEntities [["Car", "Tank", "Air", "Ship"], _spawnRadius]); + { + _blockingVehicles pushBackUnique _x; + } forEach (nearestObjects [_spawnPosition, ["Car", "Tank", "Air", "Ship"], _spawnRadius]); + if (_blockingVehicles isNotEqualTo []) exitWith { + _self call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [ + ["message", "The garage spawn area is blocked."] + ]]]; + }; + + private _className = _vehicleData getOrDefault ["classname", ""]; + if (_className isEqualTo "") exitWith { + _self call ["sendEvent", ["garage::retrieve::failure", createHashMapFromArray [ + ["message", "Stored vehicle record is missing a classname."] + ]]]; + }; + + private _vehicle = createVehicle [_className, _spawnPosition, [], 0, "CAN_COLLIDE"]; + _vehicle setDir _spawnHeading; + _vehicle setFuel (_vehicleData getOrDefault ["fuel", 0]); + _vehicle setDamage (_vehicleData getOrDefault ["damage", 0]); + + private _hitPoints = _vehicleData getOrDefault ["hit_points", createHashMap]; + private _hitPointNames = _hitPoints getOrDefault ["names", []]; + private _hitPointValues = _hitPoints getOrDefault ["values", []]; + for "_index" from 0 to ((count _hitPointNames) - 1) do { + _vehicle setHitPointDamage [_hitPointNames param [_index, ""], _hitPointValues param [_index, 0]]; + }; + + _vehicle setVariable ["forge_garage_plate", _plate, true]; + _vehicle setVariable ["forge_garage_owner_uid", getPlayerUID player, true]; + + _self set ["pendingRetrieve", createHashMapFromArray [ + ["plate", _plate], + ["vehicle", _vehicle] + ]]; + + [SRPC(garage,requestRetrieveVehicle), [getPlayerUID player, _plate]] call CFUNC(serverEvent); + }], + ["handleStoreRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _netId = _data getOrDefault ["netId", ""]; + if (_netId isEqualTo "") exitWith { + _self call ["sendEvent", ["garage::store::failure", createHashMapFromArray [ + ["message", "Select a nearby vehicle to store."] + ]]]; + }; + + private _vehicle = objectFromNetId _netId; + if (isNull _vehicle) exitWith { + _self call ["sendEvent", ["garage::store::failure", createHashMapFromArray [ + ["message", "The selected vehicle is no longer available."] + ]]]; + }; + + if (crew _vehicle isNotEqualTo []) exitWith { + _self call ["sendEvent", ["garage::store::failure", createHashMapFromArray [ + ["message", "All crew must exit the vehicle before storing it."] + ]]]; + }; + + private _rawHitPoints = getAllHitPointsDamage _vehicle; + private _hitPointsJson = toJSON (createHashMapFromArray [ + ["names", _rawHitPoints param [0, []]], + ["selections", _rawHitPoints param [1, []]], + ["values", _rawHitPoints param [2, []]] + ]); + + _self set ["pendingStoreVehicle", _vehicle]; + [SRPC(garage,requestStoreVehicle), [ + getPlayerUID player, + typeOf _vehicle, + fuel _vehicle, + damage _vehicle, + _hitPointsJson + ]] call CFUNC(serverEvent); + }], + ["handleActionResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _action = _payload getOrDefault ["action", ""]; + private _success = _payload getOrDefault ["success", false]; + private _message = _payload getOrDefault ["message", "Garage action failed."]; + + switch (_action) do { + case "retrieve": { + private _pendingRetrieve = _self getOrDefault ["pendingRetrieve", createHashMap]; + private _vehicle = _pendingRetrieve getOrDefault ["vehicle", objNull]; + + if (!_success && { !isNull _vehicle }) then { + deleteVehicle _vehicle; + }; + + _self set ["pendingRetrieve", createHashMap]; + _self call ["sendEvent", [[ + "garage::retrieve::failure", + "garage::retrieve::success" + ] select _success, createHashMapFromArray [["message", _message]]]]; + }; + case "store": { + private _vehicle = _self getOrDefault ["pendingStoreVehicle", objNull]; + + if (_success && { !isNull _vehicle }) then { + deleteVehicle _vehicle; + }; + + _self set ["pendingStoreVehicle", objNull]; + _self call ["sendEvent", [[ + "garage::store::failure", + "garage::store::success" + ] select _success, createHashMapFromArray [["message", _message]]]]; + }; + }; + + [] spawn { + sleep 0.05; + if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["refreshGarage", []]; + }; + }; + }] +]; + +GVAR(GarageUIBridge) = createHashMapObject [GVAR(GarageUIBridgeBaseClass)]; +GVAR(GarageUIBridge) diff --git a/arma/client/addons/garage/functions/fnc_initVGClass.sqf b/arma/client/addons/garage/functions/fnc_initVGClass.sqf index ee58b6f..f3901f9 100644 --- a/arma/client/addons/garage/functions/fnc_initVGClass.sqf +++ b/arma/client/addons/garage/functions/fnc_initVGClass.sqf @@ -1,46 +1,38 @@ #include "..\script_component.hpp" /* - * File: fnc_initVGarageClass.sqf + * File: fnc_initVGClass.sqf * Author: IDSolutions * Date: 2025-12-16 - * Last Update: 2025-12-19 + * Last Update: 2026-02-13 * Public: No * * Description: - * Initializes the Virtual Garage class for managing player garage unlocks. - * Provides methods for syncing, saving, and applying virtual items to BIS Garage. + * Initializes the Virtual Garage class for managing player garage unlocks. + * Provides methods for syncing, saving, and applying virtual items to BIS Garage. * - * Parameter(s): - * None + * Arguments: + * None * - * Returns: - * vGarage class object [HASHMAP OBJECT] + * Return Value: + * vGarage class object [HASHMAP OBJECT] * - * Example(s): - * [] call forge_client_locker_fnc_initVGClass; + * Example: + * call forge_client_garage_fnc_initVGClass; */ #pragma hemtt ignore_variables ["_self"] -GVAR(VGarageClass) = createHashMapObject [[ - ["#type", "IVGarageClass"], - ["#create", { +GVAR(VGBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "VGBaseClass"], + ["#create", compileFinal { + GVAR(isPreLoaded) = false; + _self set ["uid", (getPlayerUID player)]; _self set ["vGarage", createHashMap]; _self set ["isLoaded", false]; _self set ["lastSave", time]; - - private _vGarage = createHashMap; - _vGarage set ["cars", ["B_Quadbike_01_F"]]; - _vGarage set ["armor", []]; - _vGarage set ["helis", []]; - _vGarage set ["planes", []]; - _vGarage set ["naval", []]; - _vGarage set ["other", []]; - - _self set ["vGarage", _vGarage]; }], - ["init", { + ["init", compileFinal { private _uid = _self get "uid"; private _vGarage = _self get "vGarage"; @@ -49,35 +41,29 @@ GVAR(VGarageClass) = createHashMapObject [[ systemChat format ["VGarage loaded for %1", (name player)]; diag_log "[FORGE:Client:VGarage] VGarage Class Initialized!"; }], - ["save", { - params [["_sync", false, [false]]]; - + ["save", compileFinal { private _uid = _self get "uid"; - [SRPC(garage,requestSaveVG), [_uid, _sync]] call CFUNC(serverEvent); + [SRPC(garage,requestSaveVG), [_uid]] call CFUNC(serverEvent); _self set ["lastSave", time]; }], - ["sync", { - params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; + ["sync", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; private _vGarage = _self get "vGarage"; private _isLoaded = _self get "isLoaded"; - if (_data isEqualTo createHashMap) exitWith { diag_log "[FORGE:Client:VGarage] Empty data received for sync, skipping."; }; - { _vGarage set [_x, _y]; - if (_jip) then { - switch (_x) do { - case "cars": { _self call ["apply", ["cars"]]; }; - case "armor": { _self call ["apply", ["armor"]]; }; - case "helis": { _self call ["apply", ["helis"]]; }; - case "planes": { _self call ["apply", ["planes"]]; }; - case "naval": { _self call ["apply", ["naval"]]; }; - case "other": { _self call ["apply", ["other"]]; }; - default {}; - }; + switch (_x) do { + case "cars": { _self call ["apply", ["cars"]]; }; + case "armor": { _self call ["apply", ["armor"]]; }; + case "helis": { _self call ["apply", ["helis"]]; }; + case "planes": { _self call ["apply", ["planes"]]; }; + case "naval": { _self call ["apply", ["naval"]]; }; + case "other": { _self call ["apply", ["other"]]; }; + default {}; }; } forEach _data; @@ -86,31 +72,33 @@ GVAR(VGarageClass) = createHashMapObject [[ if !(_isLoaded) then { _self set ["isLoaded", true]; }; diag_log "[FORGE:Client:VGarage] Sync completed"; }], - ["get", { + ["get", compileFinal { params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; private _vGarage = _self get "vGarage"; _vGarage getOrDefault [_key, _default]; }], - ["apply", { + ["apply", compileFinal { params [["_key", "", [""]]]; private _vehicles = _self call ["get", [_key, []]]; - private _array = switch (_key) do { - case "cars": { GVAR(Cars) }; - case "armor": { GVAR(Armor) }; - case "helis": { GVAR(Helis) }; - case "planes": { GVAR(Planes) }; - case "naval": { GVAR(Naval) }; - case "other": { GVAR(Other) }; - default { [] }; - }; + private _appliedVehicles = []; { - _array append [getText (configFile >> "CfgVehicles" >> _x >> "model"), [configFile >> "CfgVehicles" >> _x]]; + _appliedVehicles append [getText (configFile >> "CfgVehicles" >> _x >> "model"), [configFile >> "CfgVehicles" >> _x]]; } forEach _vehicles; - }] -]]; -SETVAR(player,FORGE_VGarageClass,GVAR(VGarageClass)); -GVAR(VGarageClass) + switch (_key) do { + case "cars": { GVAR(Cars) = _appliedVehicles; }; + case "armor": { GVAR(Armor) = _appliedVehicles; }; + case "helis": { GVAR(Helis) = _appliedVehicles; }; + case "planes": { GVAR(Planes) = _appliedVehicles; }; + case "naval": { GVAR(Naval) = _appliedVehicles; }; + case "other": { GVAR(Other) = _appliedVehicles; }; + default {}; + }; + }] +]; + +GVAR(VGClass) = createHashMapObject [GVAR(VGBaseClass)]; +GVAR(VGClass) diff --git a/arma/client/addons/garage/functions/fnc_openUI.sqf b/arma/client/addons/garage/functions/fnc_openUI.sqf index fb8d799..85e31bd 100644 --- a/arma/client/addons/garage/functions/fnc_openUI.sqf +++ b/arma/client/addons/garage/functions/fnc_openUI.sqf @@ -1,17 +1,38 @@ #include "..\script_component.hpp" /* + * File: fnc_openUI.sqf * Author: IDSolutions + * Date: 2025-12-16 + * Last Update: 2026-01-30 + * Public: No + * + * Description: * Opens the garage UI. * * Arguments: * None * * Return Value: - * None + * UI opened [BOOL] * * Example: - * [] call forge_client_garage_fnc_openUI; - * - * Public: No + * call forge_client_garage_fnc_openUI; */ + +private _display = createDialog ["RscGarage", true]; +private _ctrl = _display displayCtrl 1006; + +_ctrl ctrlAddEventHandler ["JSDialog", { + params ["_control", "_isConfirmDialog", "_message"]; + + [_control, _isConfirmDialog, _message] call FUNC(handleUIEvents); +}]; + +if !(isNil QGVAR(GarageUIBridge)) then { + GVAR(GarageUIBridge) call ["setActiveBrowserControl", [_ctrl]]; +}; + +_ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\index.html)]; + +true; diff --git a/arma/client/addons/garage/functions/fnc_openVG.sqf b/arma/client/addons/garage/functions/fnc_openVG.sqf index 9cf71d8..ddc3d6b 100644 --- a/arma/client/addons/garage/functions/fnc_openVG.sqf +++ b/arma/client/addons/garage/functions/fnc_openVG.sqf @@ -1,23 +1,23 @@ #include "..\script_component.hpp" /* - * File: fnc_initVG.sqf + * File: fnc_openVG.sqf * Author: IDSolutions * Date: 2025-12-16 - * Last Update: 2025-12-17 + * Last Update: 2026-01-30 * Public: No * * Description: - * No description added yet. + * Opens the Virtual Garage. * - * Parameter(s): - * N/A + * Arguments: + * None * - * Returns: - * Something [BOOL] + * Return Value: + * None * - * Example(s): - * [parameter] call forge_x_component_fnc_myFunction + * Example: + * call forge_client_garage_fnc_openVG */ private _locations = (missionConfigFile >> "FORGE_CfgGarages" >> "locations") call BFUNC(getCfgData); @@ -27,57 +27,69 @@ private _locations = (missionConfigFile >> "FORGE_CfgGarages" >> "locations") ca } count _locations; BIS_fnc_garage_center = createVehicle ["Land_HelipadEmpty_F", FORGE_VehSpawnPos, [], 0, "NONE"]; - -[missionNamespace, "garageOpened", { - params ["_display", "_toggleSpace"]; - - missionNamespace setVariable ["BIS_fnc_garage_data", [ - GVAR(Cars), - GVAR(Armor), - GVAR(Helis), - GVAR(Planes), - GVAR(Naval), - GVAR(Other) - ]]; - - { - lbClear (_display displayCtrl (960 + _forEachIndex)); - } forEach BIS_fnc_garage_data; - - ["ListAdd", [_display]] call BFUNC(garage); -}] call BFUNC(addScriptedEventHandler); - BIS_fnc_garage_centerType = getText (configFile >> "CfgVehicles" >> "B_Quadbike_01_F" >> "model"); +if !(GVAR(isPreLoaded)) then { + [missionNamespace, "garageOpened", { + params ["_display", "_toggleSpace"]; + + missionNamespace setVariable ["BIS_fnc_garage_data", [ + GVAR(Cars), + GVAR(Armor), + GVAR(Helis), + GVAR(Planes), + GVAR(Naval), + GVAR(Other) + ]]; + + { + lbClear (_display displayCtrl (960 + _forEachIndex)); + } forEach BIS_fnc_garage_data; + + _display displayAddEventHandler ["KeyDown", "_this select 3"]; + { (_display displayCtrl _x) ctrlShow false } forEach [44151, 44150, 44146, 44147, 44148, 44149, 44346, 44347, 978]; + + ["ListAdd", [_display]] call BFUNC(garage); + }] call BFUNC(addScriptedEventHandler); + + [missionNamespace, "garageClosed", { + private _nearestObjects = BIS_fnc_garage_center nearEntities [["Car","Tank","Air","Ship"], 15]; + + if (!isNil "_nearestObjects") then { + private _obj = _nearestObjects select 0; + private _veh = typeOf _obj; + private _textures = getObjectTextures _obj; + private _animationNames = animationNames _obj; + + { deleteVehicle _x } forEach _nearestObjects; + private _createVehicle = createVehicle [_veh, FORGE_VehSpawnPos, [], 0, "CAN_COLLIDE"]; + + if (_textures isNotEqualTo []) then { + private _count = 0; + { + _createVehicle setObjectTextureGlobal [_count, _x]; + _count = _count + 1; + } forEach _textures; + }; + + if (_animationNames isNotEqualTo []) then { + private _animationPhase = []; + + for "_i" from 0 to count _animationNames -1 do { + _animationPhase pushBack [_animationNames select _i, _obj animationPhase (_animationNames select _i)]; + { _createVehicle animate _x; } forEach _animationPhase; + }; + }; + }; + }] call BFUNC(addScriptedEventHandler); + + GVAR(isPreLoaded) = true; +}; + +private _nearVehicles = FORGE_VehSpawnPos nearEntities [["Car", "Tank", "Air", "Ship"], 5]; +if (_nearVehicles isNotEqualTo []) exitWith { + private _params = ["warning", "Virtual Garage", "Vehicle spawn position is blocked. Please move the vehicle before accessing the garage.", 3000]; + EGVAR(notifications,NotificationClass) call ["create", _params]; +}; + ["Open", true] call BFUNC(garage); - -[missionNamespace, "garageClosed", { - private _nearestObjects = BIS_fnc_garage_center nearEntities [["Car","Tank","Air","Ship"], 15]; - - if (!isNil "_nearestObjects") then { - private _obj = _nearestObjects select 0; - private _veh = typeOf _obj; - private _textures = getObjectTextures _obj; - private _animationNames = animationNames _obj; - - { deleteVehicle _x } forEach _nearestObjects; - private _createVehicle = createVehicle [_veh, FORGE_VehSpawnPos, [], 0, "CAN_COLLIDE"]; - - if (_textures isNotEqualTo []) then { - private _count = 0; - { - _createVehicle setObjectTextureGlobal [_count, _x]; - _count = _count + 1; - } forEach _textures; - }; - - if (_animationNames isNotEqualTo []) then { - private _animationPhase = []; - - for "_i" from 0 to count _animationNames -1 do { - _animationPhase pushBack [_animationNames select _i, _obj animationPhase (_animationNames select _i)]; - { _createVehicle animate _x; } forEach _animationPhase; - }; - }; - }; -}] call BFUNC(addScriptedEventHandler); diff --git a/arma/client/addons/garage/ui/RscGarage.hpp b/arma/client/addons/garage/ui/RscGarage.hpp index 2507825..e6f392d 100644 --- a/arma/client/addons/garage/ui/RscGarage.hpp +++ b/arma/client/addons/garage/ui/RscGarage.hpp @@ -1,5 +1,5 @@ class RscGarage { - idd = 1000; + idd = 1005; fadeIn = 0; fadeOut = 0; duration = 1e011; @@ -10,7 +10,7 @@ class RscGarage { class controls { class IFrame: RscText { type = 106; - idc = 1001; + idc = 1006; x = "safeZoneXAbs"; y = "safeZoneY"; w = "safeZoneWAbs"; diff --git a/arma/client/addons/garage/ui/_site/garage-ui.css b/arma/client/addons/garage/ui/_site/garage-ui.css new file mode 100644 index 0000000..0f5a3cb --- /dev/null +++ b/arma/client/addons/garage/ui/_site/garage-ui.css @@ -0,0 +1 @@ +:root{--garage-shell-bg:#e4e3df;--garage-surface:#f5f3ef;--garage-surface-alt:#ece8e2;--garage-border:#4a5b6e33;--garage-border-strong:#142e4f2e;--garage-text-main:#1f2d3d;--garage-text-muted:#6a7787;--garage-text-subtle:#8792a0;--garage-accent:#12365d;--garage-accent-soft:#dbe7f3;--garage-accent-line:#12365d1f;--garage-warning:#8f5f26}*{box-sizing:border-box}html,body{width:100%;height:100%;margin:0;overflow:hidden}body{color:var(--garage-text-main);background:var(--garage-shell-bg);font-family:Segoe UI,Trebuchet MS,sans-serif}button,input{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.72}:focus-visible{outline-offset:2px;outline:2px solid #12365d59}#app{width:100%;height:100%}.garage-shell{background:var(--garage-shell-bg);flex-direction:column;width:100%;height:100%;display:flex;overflow:hidden}.garage-layout{flex:1;grid-template-columns:308px minmax(0,1fr);gap:1.25rem;width:min(100%,1613px);min-height:0;margin:0 auto;padding:1.25rem;display:grid}.garage-sidebar,.garage-main{flex-direction:column;gap:1rem;min-height:0;display:flex}.garage-main{overflow:hidden}.garage-module,.garage-panel,.garage-card{background:linear-gradient(180deg, var(--garage-surface) 0%, var(--garage-surface-alt) 100%);border:1px solid var(--garage-border);border-radius:1.35rem}.garage-module,.garage-card{padding:1rem}.garage-module{align-content:start;gap:.85rem;display:grid}.garage-panel{flex-direction:column;flex:auto;min-height:0;display:flex;overflow:hidden}.garage-panel-header,.garage-module-header,.garage-card-header{justify-content:space-between;align-items:center;gap:1rem;display:flex}.garage-panel-header{padding:1rem 1rem 0}.garage-module-header{align-items:flex-start}.garage-panel-intro{border-bottom:1px solid var(--garage-accent-line);padding:0 1rem 1rem}.garage-dashboard{flex:1;grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-items:stretch;gap:1rem;min-height:0;padding:1rem;display:grid}.garage-list-card,.garage-detail-card{flex-direction:column;min-height:0;display:flex}.garage-detail-card{grid-column:1/-1}.garage-scroll-body{flex:1;gap:.8rem;min-height:20rem;max-height:24rem;padding-right:.2rem;display:grid;overflow:auto}.garage-detail-body{padding-top:.95rem}.garage-detail-grid{grid-template-columns:minmax(0,1.25fr) minmax(280px,.85fr);gap:1rem;display:grid}.garage-detail-meta,.garage-summary-grid,.garage-search-actions,.garage-category-grid,.garage-action-row,.garage-inline-meters,.garage-hitpoint-grid,.garage-footer{gap:.75rem;display:grid}.garage-detail-meta{grid-template-columns:repeat(3,minmax(0,1fr));margin-bottom:1rem}.garage-summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.garage-summary-grid>:last-child{grid-column:1/-1}.garage-search-actions,.garage-action-row,.garage-category-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:.65rem}.garage-footer-bar{border-top:1px solid #12365d1a;width:100%}.garage-footer{grid-template-columns:repeat(3,minmax(0,1fr));width:min(100%,1613px);margin:0 auto;padding:.95rem 1.25rem 1.15rem}.garage-meter-stack{gap:.75rem;margin-bottom:1rem;display:grid}.garage-eyebrow,.garage-footer-title,.garage-stat-label,.garage-meter-label,.garage-hitpoint-selection{letter-spacing:.18em;text-transform:uppercase;color:var(--garage-text-subtle);font-size:.68rem;font-weight:700}.garage-title,.garage-section-title{letter-spacing:-.02em;color:var(--garage-text-main);margin:.16rem 0 0;font-weight:700}.garage-title{font-size:1.1rem}.garage-section-title{font-size:1.05rem}.garage-copy,.garage-detail-note,.garage-empty-copy,.garage-footer-copy,.garage-vehicle-meta,.garage-detail-caption{color:var(--garage-text-muted);margin:0;font-size:.92rem;line-height:1.48}.garage-pill,.garage-badge{letter-spacing:.1em;text-transform:uppercase;background:var(--garage-accent-soft);color:var(--garage-accent);border-radius:999px;justify-content:center;align-items:center;padding:.48rem .8rem;font-size:.74rem;font-weight:700;display:inline-flex}.garage-badge.is-warning{color:var(--garage-warning);background:#f6e2c1e0}.garage-search-form{gap:.75rem;display:grid}.garage-search-input{border:1px solid var(--garage-border);width:100%;height:2.9rem;color:var(--garage-text-main);background:#ffffffbf;border-radius:.8rem;padding:0 .95rem}.garage-stat-card{border:1px solid var(--garage-border);background:#ffffff7a;border-radius:.85rem;flex-direction:column;gap:.3rem;min-width:0;padding:.85rem;display:flex}.garage-stat-card.is-accent{background:linear-gradient(#edf3f9eb 0%,#dfe8f2b8 100%)}.garage-stat-card.is-danger{background:linear-gradient(#fef2f2f2 0%,#fce1e1d1 100%);border-color:#dc979761}.garage-stat-value{color:var(--garage-text-main);overflow-wrap:anywhere;word-break:break-word;font-size:1rem;font-weight:700;line-height:1.3}.garage-chip{border:1px solid var(--garage-border);min-height:2.6rem;color:var(--garage-text-muted);letter-spacing:.08em;text-transform:uppercase;background:#ffffff85;border-radius:.85rem;padding:.68rem .9rem;font-size:.8rem;font-weight:700}.garage-chip.is-active{background:var(--garage-accent-soft);color:var(--garage-accent);border-color:#12365d33}.garage-vehicle-item{border:1px solid var(--garage-border);width:100%;color:inherit;text-align:left;background:#ffffff7a;border-radius:.95rem;padding:.9rem}.garage-vehicle-item.is-selected{background:linear-gradient(#edf3f9f5 0%,#dfe8f2bd 100%);border-color:#12365d3d;box-shadow:0 16px 26px #12365d14}.garage-vehicle-item-head,.garage-meter-label-row,.garage-subsystem-header,.garage-hitpoint-row{justify-content:space-between;align-items:center;gap:.75rem;display:flex}.garage-vehicle-copy,.garage-hitpoint-copy,.garage-footer-block{flex-direction:column;gap:.18rem;min-width:0;display:flex}.garage-vehicle-title,.garage-hitpoint-name,.garage-hitpoint-value{color:var(--garage-text-main);font-size:.9rem;font-weight:700}.garage-meter{gap:.32rem;display:grid}.garage-meter-track{background:#12365d14;border-radius:999px;width:100%;height:.45rem;overflow:hidden}.garage-meter-value{color:var(--garage-text-main);font-size:.78rem;font-weight:700}.garage-meter-fill{border-radius:inherit;height:100%;display:block}.garage-meter-fill.is-health{background:linear-gradient(90deg,#2f7d5b 0%,#4eaa82 100%)}.garage-meter-fill.is-fuel{background:linear-gradient(90deg,#12365d 0%,#3c6792 100%)}.garage-btn{border:1px solid var(--garage-border-strong);letter-spacing:.08em;text-transform:uppercase;border-radius:.8rem;min-height:2.75rem;padding:.72rem 1rem;font-size:.82rem;font-weight:700}.garage-btn-primary{color:var(--garage-accent);background:#ffffffad}.garage-btn-primary:hover{background:#dbe7f3e0}.garage-btn-secondary{color:var(--garage-text-muted);background:#ffffff6b}.garage-btn-secondary:hover{color:var(--garage-text-main);background:#fff9}.garage-hitpoint-row{border:1px solid var(--garage-border);background:#ffffff85;border-radius:.85rem;padding:.72rem .78rem}.garage-detail-empty,.garage-empty-state{flex-direction:column;justify-content:center;align-items:flex-start;min-height:100%;display:flex}.garage-empty-title{color:var(--garage-text-main);margin:0 0 .35rem;font-size:1rem;font-weight:700}.garage-empty-inline{border:1px dashed var(--garage-border);color:var(--garage-text-muted);background:#ffffff5c;border-radius:.85rem;padding:.9rem}.garage-toast-stack{z-index:10;flex-direction:column;gap:.65rem;display:flex;position:fixed;top:1.2rem;right:1.5rem}.garage-toast{border:1px solid var(--garage-border);background:#fff;border-radius:.9rem;max-width:24rem;padding:.85rem 1rem;font-size:.92rem;box-shadow:0 14px 28px #10223824}.garage-toast.is-success{color:#166534;background:#ecfdf5;border-color:#bbf7d0}.garage-toast.is-error{color:#991b1b;background:#fef2f2;border-color:#fecaca}@media (width<=1440px){.garage-layout{grid-template-columns:288px minmax(0,1fr)}.garage-detail-grid{grid-template-columns:1fr}}@media (width<=1120px){.garage-layout{grid-template-columns:1fr;overflow:auto}.garage-main,.garage-sidebar{min-height:auto}.garage-dashboard{grid-template-columns:1fr}.garage-detail-card{grid-column:auto}.garage-scroll-body{min-height:16rem;max-height:none}.garage-footer{grid-template-columns:1fr}} \ No newline at end of file diff --git a/arma/client/addons/garage/ui/_site/garage-ui.js b/arma/client/addons/garage/ui/_site/garage-ui.js new file mode 100644 index 0000000..2f7fb77 --- /dev/null +++ b/arma/client/addons/garage/ui/_site/garage-ui.js @@ -0,0 +1 @@ +!function(){const e=window.ForgeWebUI;(window.GarageApp=window.GarageApp||{}).runtime=e,window.AppRuntime=e}(),function(){const e=window.GarageApp=window.GarageApp||{},a={garageName:"Vehicle Garage",capacityUsed:0,capacityMax:5,nearbyCount:0,spawnBlocked:!1,spawnStatus:"Ready"},t={vehicles:[]},r={vehicles:[]};function s(e,a){var t;Object.keys(e).forEach(a=>delete e[a]),Object.assign(e,(t=a,JSON.parse(JSON.stringify(t))))}e.data={categories:[{id:"all",label:"All"},{id:"car",label:"Cars"},{id:"armor",label:"Armor"},{id:"air",label:"Air"},{id:"naval",label:"Naval"},{id:"other",label:"Other"}],session:Object.assign({},a),garage:Object.assign({},t),nearby:Object.assign({},r),applyHydratePayload(e){s(this.session,Object.assign({},a,e?.session||{})),s(this.garage,Object.assign({},t,e?.garage||{})),s(this.nearby,Object.assign({},r,e?.nearby||{}))}}}(),function(){const e=window.GarageApp=window.GarageApp||{},{createSignal:a}=e.runtime;e.store=new class{constructor(){[this.getSelectedKind,this.setSelectedKind]=a(""),[this.getSelectedId,this.setSelectedId]=a(""),[this.getSearchQuery,this.setSearchQuery]=a(""),[this.getCategoryFilter,this.setCategoryFilter]=a("all"),[this.getPendingAction,this.setPendingAction]=a(""),[this.getNotice,this.setNotice]=a({type:"",text:""})}getSelection(){return{id:this.getSelectedId(),kind:this.getSelectedKind()}}clearSelection(){this.setSelectedKind(""),this.setSelectedId("")}select(e,a){this.setSelectedKind(String(e||"")),this.setSelectedId(String(a||""))}startAction(e){this.setPendingAction(String(e||""))}finishAction(){this.setPendingAction("")}matchesSelection(e){if(!e||"object"!=typeof e)return!1;const a=this.getSelection();return!(!a.kind||!a.id)&&("stored"===a.kind?"stored"===e.entryKind&&String(e.plate||"")===a.id:"nearby"===a.kind&&("nearby"===e.entryKind&&String(e.netId||"")===a.id))}ensureSelection(){const a=Array.isArray(e.data?.garage?.vehicles)?e.data.garage.vehicles:[],t=Array.isArray(e.data?.nearby?.vehicles)?e.data.nearby.vehicles:[];if([...a,...t].some(e=>this.matchesSelection(e)))return;const r=a[0]||null;if(r)return void this.select("stored",r.plate||"");const s=t[0]||null;s?this.select("nearby",s.netId||""):this.clearSelection()}hydrateFromPayload(){this.finishAction(),this.ensureSelection()}}}(),function(){const e=window.GarageApp=window.GarageApp||{},a=e.store,t=window.ForgeWebUI.createBridge({closeEvent:"garage::close",globalName:"ForgeBridge",readyEvent:"garage::ready"});function r(t){e.data.applyHydratePayload(t),a.hydrateFromPayload(t)}t.on("garage::hydrate",r),t.on("garage::sync",r),t.on("garage::retrieve::success",t=>{a.finishAction(),e.actions&&e.actions.showNotice("success",t.message||"Vehicle retrieved from the garage.")}),t.on("garage::retrieve::failure",t=>{a.finishAction(),e.actions&&e.actions.showNotice("error",t.message||"Unable to retrieve vehicle.")}),t.on("garage::store::success",t=>{a.finishAction(),e.actions&&e.actions.showNotice("success",t.message||"Vehicle stored in the garage.")}),t.on("garage::store::failure",t=>{a.finishAction(),e.actions&&e.actions.showNotice("error",t.message||"Unable to store vehicle.")}),e.bridge={notifyReady:function(){return t.ready({loaded:!0})},receive:t.receive,requestClose:function(){return t.close({})},requestRefresh:function(){return t.send("garage::refresh",{})},requestRetrieve:function(e){return t.send("garage::vehicle::retrieve::request",e)},requestStore:function(e){return t.send("garage::vehicle::store::request",e)},sendEvent:t.send}}(),function(){const e=window.GarageApp=window.GarageApp||{},a=e.store;let t=null;function r(){const t=a.getSelection();return"stored"===t.kind?(Array.isArray(e.data?.garage?.vehicles)?e.data.garage.vehicles:[]).find(e=>String(e.plate||"")===t.id)||null:"nearby"===t.kind&&(Array.isArray(e.data?.nearby?.vehicles)?e.data.nearby.vehicles:[]).find(e=>String(e.netId||"")===t.id)||null}function s(e,r){a.setNotice({type:e,text:r}),t&&clearTimeout(t),t=setTimeout(()=>{a.setNotice({type:"",text:""}),t=null},3200)}e.actions={showNotice:s,closeGarage:function(){const a=e.bridge;if(a&&"function"==typeof a.requestClose){if(a.requestClose())return!0}return s("error","Garage bridge is unavailable."),!1},refreshGarage:function(){const a=e.bridge;if(a&&"function"==typeof a.requestRefresh){if(a.requestRefresh())return!0}return s("error","Garage refresh bridge is unavailable."),!1},applySearchQuery:function(e){a.setSearchQuery(String(e||"").trim())},clearSearch:function(){a.setSearchQuery("")},selectCategory:function(e){a.setCategoryFilter(String(e||"all").trim()||"all")},selectEntry:function(e,t){a.select(e,t)},getSelectedEntry:r,requestRetrieveSelected:function(){const t=r();if(!t||"stored"!==t.entryKind)return s("error","Select a stored vehicle to retrieve."),!1;if(e.data?.session?.spawnBlocked)return s("error","The garage spawn area is blocked."),!1;const i=e.bridge;return i&&"function"==typeof i.requestRetrieve?(a.startAction("retrieve"),!!i.requestRetrieve({plate:t.plate||""})||(a.finishAction(),s("error","Garage retrieve bridge is unavailable."),!1)):(s("error","Garage retrieve bridge is unavailable."),!1)},requestStoreSelected:function(){const t=r();if(!t||"nearby"!==t.entryKind)return s("error","Select a nearby vehicle to store."),!1;if(!1===t.isEmpty)return s("error","All crew must exit the vehicle before storing it."),!1;const i=e.bridge;return i&&"function"==typeof i.requestStore?(a.startAction("store"),!!i.requestStore({netId:t.netId||""})||(a.finishAction(),s("error","Garage store bridge is unavailable."),!1)):(s("error","Garage store bridge is unavailable."),!1)}}}(),function(){const e=window.GarageApp=window.GarageApp||{},{h:a}=e.runtime,t=window.SharedUI.componentFns.WindowTitleBar,r=e.store,s=e.actions,{categories:i,garage:n,nearby:c,session:l}=e.data;function o(e){return Math.max(0,Math.min(100,Math.round(100*Number(e||0))))}function g(e){const a=i.find(a=>a.id===String(e||"other").toLowerCase());return a?a.label:"Other"}function d(e){return`${Math.round(Number(e||0))} m`}function u(e){return String(e||"").trim()||"Untracked"}function m(e,a){return(e||[]).filter(e=>("all"===a.categoryFilter||String(e.category||"").toLowerCase()===a.categoryFilter)&&function(e,a){const t=String(e||"").trim().toLowerCase();return!t||a.some(e=>String(e||"").toLowerCase().includes(t))}(a.searchQuery,[e.displayName,e.classname,e.plate,e.netId,e.category]))}function p(e,t,r=""){return a("div",{className:r?`garage-stat-card is-${r}`:"garage-stat-card"},a("span",{className:"garage-stat-label"},e),a("span",{className:"garage-stat-value"},t))}function h(e,t,r){return a("div",{className:"garage-meter"},a("div",{className:"garage-meter-label-row"},a("span",{className:"garage-meter-label"},e),a("span",{className:"garage-meter-value"},`${t}%`)),a("div",{className:"garage-meter-track"},a("span",{className:`garage-meter-fill is-${r}`,style:{width:`${t}%`}})))}function y(e,t,r,i,n){return a("section",{className:"garage-card garage-list-card"},a("div",{className:"garage-card-header"},a("div",null,a("span",{className:"garage-eyebrow"},t),a("h2",{className:"garage-section-title"},e)),a("span",{className:"garage-pill"},`${i.length} ${1===i.length?"Vehicle":"Vehicles"}`)),a("div",{className:"garage-card-body garage-scroll-body","data-preserve-scroll-id":r},i.length>0?i.map(e=>function(e,t){const r="stored"===e.entryKind?String(e.plate||""):String(e.netId||""),i="nearby"===e.entryKind;return a("button",{type:"button",className:(n=e,c=t,n&&c&&String(n.entryKind||"")===String(c.entryKind||"")&&String(n.plate||"")===String(c.plate||"")&&String(n.netId||"")===String(c.netId||"")?"garage-vehicle-item is-selected":"garage-vehicle-item"),onClick:()=>s.selectEntry(e.entryKind,r)},a("div",{className:"garage-vehicle-item-head"},a("div",{className:"garage-vehicle-copy"},a("span",{className:"garage-vehicle-title"},e.displayName||e.classname||"Vehicle"),a("span",{className:"garage-vehicle-meta"},i?`Nearby ${d(e.distance)}`:`Plate ${u(e.plate)}`)),a("span",{className:i&&!1===e.isEmpty?"garage-badge is-warning":"garage-badge"},i?!1===e.isEmpty?"Crewed":"Empty":g(e.category))),a("div",{className:"garage-inline-meters"},h("Health",o(e.health),"health"),h("Fuel",o(e.fuel),"fuel")));var n,c}(e,n)):a("div",{className:"garage-empty-state"},a("h3",{className:"garage-empty-title"},"No matching vehicles"),a("p",{className:"garage-empty-copy"},"Adjust the current search or category filter to view more records."))))}function b(e){const t=(Array.isArray(e)?e:[]).slice().sort((e,a)=>Number(a.value||0)-Number(e.value||0)).slice(0,6).filter(e=>Number(e.value||0)>0);return 0===t.length?a("div",{className:"garage-empty-inline"},"No subsystem damage reported."):a("div",{className:"garage-hitpoint-grid"},t.map(e=>{return a("div",{className:"garage-hitpoint-row"},a("div",{className:"garage-hitpoint-copy"},a("span",{className:"garage-hitpoint-name"},(t=e.name,String(t||"").replace(/^Hit/i,"").replace(/([a-z])([A-Z])/g,"$1 $2").replace(/_/g," ").trim()||"Subsystem")),e.selection?a("span",{className:"garage-hitpoint-selection"},e.selection):null),a("span",{className:"garage-hitpoint-value"},`${Math.round(100*Number(e.value||0))}%`));var t}))}e.components=e.components||{},e.components.App=function(){const e={categoryFilter:r.getCategoryFilter(),notice:r.getNotice(),pendingAction:r.getPendingAction(),searchQuery:r.getSearchQuery(),selectedId:r.getSelectedId(),selectedKind:r.getSelectedKind()},v=function(e){return"stored"===e.selectedKind?(n.vehicles||[]).find(a=>String(a.plate||"")===e.selectedId)||null:"nearby"===e.selectedKind&&(c.vehicles||[]).find(a=>String(a.netId||"")===e.selectedId)||null}(e),N=m(n.vehicles||[],e),f=m(c.vehicles||[],e),S=e.searchQuery?`Search: ${e.searchQuery}`:"Live";return a("div",{className:"garage-shell"},t({kicker:"FORGE Logistics",title:"Vehicle Garage",onClose:()=>s.closeGarage(),closeLabel:"Close garage interface"}),e.notice.text?a("div",{className:"garage-toast-stack"},a("div",{className:"error"===e.notice.type?"garage-toast is-error":"garage-toast is-success"},e.notice.text)):null,a("div",{className:"garage-layout"},a("aside",{className:"garage-sidebar"},a("section",{className:"garage-module"},a("div",{className:"garage-module-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Search"),a("h2",{className:"garage-section-title"},"Vehicle Records")),a("span",{className:"garage-pill"},S)),a("div",{className:"garage-search-form"},a("input",{id:"garage-search-input",type:"text",className:"garage-search-input",placeholder:"Search by name, plate, or category",value:e.searchQuery}),a("div",{className:"garage-search-actions"},a("button",{type:"button",className:"garage-btn garage-btn-primary",onClick:()=>s.applySearchQuery(document.getElementById("garage-search-input")?.value||"")},"Apply Search"),a("button",{type:"button",className:"garage-btn garage-btn-secondary",onClick:()=>s.clearSearch()},"Clear")))),a("section",{className:"garage-module"},a("div",{className:"garage-module-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Filter"),a("h2",{className:"garage-section-title"},"Vehicle Categories"))),a("div",{className:"garage-category-grid"},i.map(t=>a("button",{type:"button",className:e.categoryFilter===t.id?"garage-chip is-active":"garage-chip",onClick:()=>s.selectCategory(t.id)},t.label)))),a("section",{className:"garage-module"},a("div",{className:"garage-module-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Status"),a("h2",{className:"garage-section-title"},"Garage Summary")),a("button",{type:"button",className:"garage-btn garage-btn-secondary",disabled:Boolean(e.pendingAction),onClick:()=>s.refreshGarage()},"Refresh")),a("div",{className:"garage-summary-grid"},p("Stored",`${l.capacityUsed}/${l.capacityMax}`),p("Nearby",l.nearbyCount,"accent"),p("Spawn Lane",l.spawnStatus,l.spawnBlocked?"danger":"")))),a("main",{className:"garage-main"},a("section",{className:"garage-panel"},a("div",{className:"garage-panel-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Operations Bay"),a("h1",{className:"garage-title"},l.garageName||"Vehicle Garage")),a("span",{className:"garage-pill"},`${l.capacityUsed}/${l.capacityMax} Stored`)),a("div",{className:"garage-panel-intro"},a("p",{className:"garage-copy"},"Retrieve stored vehicles into the active spawn lane or store nearby empty vehicles back into persistent ownership records.")),a("div",{className:"garage-dashboard"},y("Stored Vehicles","Persistent Records","garage-stored-list",N,v),y("Nearby Vehicles","Store Window","garage-nearby-list",f,v),function(e,t){if(!e)return a("section",{className:"garage-card garage-detail-card"},a("div",{className:"garage-card-header"},a("div",null,a("span",{className:"garage-eyebrow"},"Selection"),a("h2",{className:"garage-section-title"},"Vehicle Detail"))),a("div",{className:"garage-card-body garage-detail-empty"},a("h3",{className:"garage-empty-title"},"Select a vehicle"),a("p",{className:"garage-empty-copy"},"Choose a stored record to retrieve or a nearby vehicle to store.")));const r="stored"===e.entryKind,i=String(t.pendingAction||""),n="retrieve"===i||"store"===i,c=r&&!l.spawnBlocked&&!n,m=!r&&!1!==e.isEmpty&&!n;return a("section",{className:"garage-card garage-detail-card"},a("div",{className:"garage-card-header"},a("div",null,a("span",{className:"garage-eyebrow"},r?"Stored Record":"Nearby Vehicle"),a("h2",{className:"garage-section-title"},e.displayName||e.classname||"Vehicle")),a("span",{className:"nearby"===e.entryKind&&!1===e.isEmpty?"garage-badge is-warning":"garage-badge"},r?`Plate ${u(e.plate)}`:!1===e.isEmpty?"Crewed":"Ready")),a("div",{className:"garage-card-body garage-detail-body"},a("div",{className:"garage-detail-grid"},a("div",{className:"garage-detail-copy"},a("div",{className:"garage-detail-meta"},p("Category",g(e.category)),p("Status",(y=e)?"stored"===y.entryKind?"Stored":!1===y.isEmpty?"Crewed":"Ready":"-","nearby"===e.entryKind&&!1===e.isEmpty?"danger":""),p(r?"Record":"Distance",r?u(e.plate):d(e.distance),r?"":"accent")),a("div",{className:"garage-meter-stack"},h("Health",o(e.health),"health"),h("Fuel",o(e.fuel),"fuel")),a("div",{className:"garage-action-row"},r?a("button",{type:"button",className:"garage-btn garage-btn-primary",disabled:!c,onClick:()=>s.requestRetrieveSelected()},"retrieve"===i?"Retrieving...":"Retrieve Vehicle"):a("button",{type:"button",className:"garage-btn garage-btn-primary",disabled:!m,onClick:()=>s.requestStoreSelected()},"store"===i?"Storing...":"Store Vehicle"),a("button",{type:"button",className:"garage-btn garage-btn-secondary",disabled:n,onClick:()=>s.refreshGarage()},"Refresh")),a("p",{className:"garage-detail-note"},r?l.spawnBlocked?"The garage spawn lane is currently blocked.":"Retrieve this stored vehicle into the active spawn lane.":!1===e.isEmpty?"Only empty nearby vehicles can be stored.":"Store this nearby vehicle back into persistent garage storage.")),a("div",{className:"garage-detail-subsystems"},a("div",{className:"garage-subsystem-header"},a("span",{className:"garage-eyebrow"},"Subsystems"),a("span",{className:"garage-detail-caption"},"Highest damage first")),b(e.hitPoints)))));var y}(v,e))))),a("footer",{className:"garage-footer-bar"},a("div",{className:"garage-footer"},a("div",{className:"garage-footer-block"},a("span",{className:"garage-footer-title"},"Storage Capacity"),a("span",{className:"garage-footer-copy"},`${l.capacityUsed} of ${l.capacityMax} vehicle slot(s) are currently occupied.`)),a("div",{className:"garage-footer-block"},a("span",{className:"garage-footer-title"},"Retrieval Window"),a("span",{className:"garage-footer-copy"},l.spawnBlocked?"Spawn lane is blocked. Clear the bay before retrieving another vehicle.":"Spawn lane is clear. Stored vehicles can be retrieved immediately.")),a("div",{className:"garage-footer-block"},a("span",{className:"garage-footer-title"},"Store Rules"),a("span",{className:"garage-footer-copy"},"Only nearby empty vehicles can be stored. Nearby count updates from the live world state.")))))}}(),function(){const e=window.ForgeWebUI,a=window.GarageApp;e.createApp({name:"garage",root:"#app",setup({root:t}){e.mount(t,()=>a.components.App(),{preserveScroll:!0}),a.bridge&&a.bridge.notifyReady()}}).start()}(); \ No newline at end of file diff --git a/arma/client/addons/garage/ui/_site/index.html b/arma/client/addons/garage/ui/_site/index.html index 8fd7d94..02cdff9 100644 --- a/arma/client/addons/garage/ui/_site/index.html +++ b/arma/client/addons/garage/ui/_site/index.html @@ -1,205 +1 @@ - - - - - - - Vehicle Garage - - - - - - -
- -
- -
-

Vehicle Garage

-

Vehicle Management System

-
-
-
- Stored - 12 -
-
- Active - 2 -
-
- Capacity - 20 -
-
-
- -
-
- - -
- -
-
-

Filters

-
-
- -
-

Status

-
- - - -
-
- - -
-

Vehicle Type

-
- - - - - -
-
- - -
-

Search

- -
-
-
- - -
-
-

Your Vehicles

-
-
-
- -
-
-
- - -
-
-

Vehicle Details

-
-
-
-
🚗
-

Select a vehicle to view details

-
- - -
-
-
-
- - - - - +FORGE Vehicle Garage
\ No newline at end of file diff --git a/arma/client/addons/garage/ui/_site/script.js b/arma/client/addons/garage/ui/_site/script.js deleted file mode 100644 index 6094cac..0000000 --- a/arma/client/addons/garage/ui/_site/script.js +++ /dev/null @@ -1,317 +0,0 @@ -/** - * Vehicle Garage Interface - * Handles vehicle management with spawn and store actions - */ - -// Mock data - sample vehicles -const mockData = { - vehicles: [ - // Cars - { id: 1, name: "Sedan", type: "car", icon: "🚗", status: "stored", condition: 95, fuel: 80, location: "Garage A", seats: 4, speed: "180 km/h", cargo: "200 kg" }, - { id: 2, name: "Sports Car", type: "car", icon: "🏎️", status: "stored", condition: 100, fuel: 100, location: "Garage A", seats: 2, speed: "250 km/h", cargo: "50 kg" }, - { id: 3, name: "SUV", type: "car", icon: "🚙", status: "active", condition: 85, fuel: 60, location: "In Use", seats: 6, speed: "160 km/h", cargo: "400 kg" }, - { id: 4, name: "Hatchback", type: "car", icon: "🚗", status: "stored", condition: 90, fuel: 75, location: "Garage B", seats: 4, speed: "170 km/h", cargo: "250 kg" }, - - // Trucks - { id: 5, name: "Pickup Truck", type: "truck", icon: "🚛", status: "stored", condition: 88, fuel: 70, location: "Garage A", seats: 2, speed: "140 km/h", cargo: "800 kg" }, - { id: 6, name: "Delivery Van", type: "truck", icon: "🚚", status: "stored", condition: 92, fuel: 85, location: "Garage B", seats: 3, speed: "130 km/h", cargo: "1200 kg" }, - { id: 7, name: "Heavy Truck", type: "truck", icon: "🚛", status: "active", condition: 75, fuel: 50, location: "In Use", seats: 2, speed: "120 km/h", cargo: "2000 kg" }, - { id: 8, name: "Box Truck", type: "truck", icon: "📦", status: "stored", condition: 80, fuel: 65, location: "Garage A", seats: 3, speed: "110 km/h", cargo: "1500 kg" }, - - // Aircraft - { id: 9, name: "Helicopter", type: "air", icon: "🚁", status: "stored", condition: 95, fuel: 90, location: "Helipad", seats: 6, speed: "280 km/h", cargo: "500 kg" }, - { id: 10, name: "Light Plane", type: "air", icon: "✈️", status: "stored", condition: 100, fuel: 100, location: "Hangar", seats: 4, speed: "320 km/h", cargo: "300 kg" }, - - // Boats - { id: 11, name: "Speedboat", type: "sea", icon: "🚤", status: "stored", condition: 93, fuel: 80, location: "Marina", seats: 4, speed: "100 km/h", cargo: "150 kg" }, - { id: 12, name: "Yacht", type: "sea", icon: "🛥️", status: "stored", condition: 98, fuel: 95, location: "Marina", seats: 12, speed: "60 km/h", cargo: "800 kg" } - ] -}; - -// State -let selectedVehicle = null; -let statusFilter = 'all'; -let typeFilter = 'all'; -let searchQuery = ''; - -// Icons by type -const typeIcons = { - car: '🚗', - truck: '🚛', - air: '🚁', - sea: '🚤' -}; - -// Initialize -function initGarage() { - console.log('Garage interface initializing...'); - - setupEventHandlers(); - renderVehicles(); - updateStats(); - - console.log('Garage interface initialized'); -} - -// Event Handlers -function setupEventHandlers() { - // Close button - const closeBtn = document.querySelector('.close-btn'); - if (closeBtn) { - closeBtn.addEventListener('click', () => { - console.log('Closing garage...'); - sendEvent('garage::close', {}); - }); - } - - // Status filters - const filterBtns = document.querySelectorAll('.filter-btn'); - filterBtns.forEach(btn => { - btn.addEventListener('click', () => { - filterBtns.forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - statusFilter = btn.dataset.filter; - renderVehicles(); - }); - }); - - // Type filters - const typeItems = document.querySelectorAll('.type-item'); - typeItems.forEach(item => { - item.addEventListener('click', () => { - typeItems.forEach(i => i.classList.remove('active')); - item.classList.add('active'); - typeFilter = item.dataset.type; - renderVehicles(); - }); - }); - - // Search - const searchInput = document.getElementById('searchInput'); - if (searchInput) { - searchInput.addEventListener('input', (e) => { - searchQuery = e.target.value.toLowerCase(); - renderVehicles(); - }); - } - - // Spawn button - const spawnBtn = document.getElementById('spawnBtn'); - if (spawnBtn) { - spawnBtn.addEventListener('click', () => { - if (selectedVehicle) { - spawnVehicle(selectedVehicle); - } - }); - } - - // Store button - const storeBtn = document.getElementById('storeBtn'); - if (storeBtn) { - storeBtn.addEventListener('click', () => { - if (selectedVehicle) { - storeVehicle(selectedVehicle); - } - }); - } -} - -// Render vehicles -function renderVehicles() { - const vehiclesGrid = document.getElementById('vehiclesGrid'); - if (!vehiclesGrid) return; - - vehiclesGrid.innerHTML = ''; - - // Filter vehicles - let filtered = mockData.vehicles; - - // Status filter - if (statusFilter !== 'all') { - filtered = filtered.filter(v => v.status === statusFilter); - } - - // Type filter - if (typeFilter !== 'all') { - filtered = filtered.filter(v => v.type === typeFilter); - } - - // Search filter - if (searchQuery) { - filtered = filtered.filter(v => - v.name.toLowerCase().includes(searchQuery) || - v.type.toLowerCase().includes(searchQuery) - ); - } - - // Render vehicles - filtered.forEach(vehicle => { - const card = document.createElement('div'); - card.className = 'vehicle-card'; - if (selectedVehicle && selectedVehicle.id === vehicle.id) { - card.classList.add('selected'); - } - - card.innerHTML = ` -
${vehicle.icon}
-
${vehicle.name}
-
${vehicle.type}
-
${vehicle.status}
- `; - - card.addEventListener('click', () => selectVehicle(vehicle)); - vehiclesGrid.appendChild(card); - }); - - console.log(`Rendered ${filtered.length} vehicles`); -} - -// Select vehicle -function selectVehicle(vehicle) { - selectedVehicle = vehicle; - - // Update selected state in grid - document.querySelectorAll('.vehicle-card').forEach(card => { - card.classList.remove('selected'); - }); - event.currentTarget.classList.add('selected'); - - // Show details - showVehicleDetails(vehicle); -} - -// Show vehicle details -function showVehicleDetails(vehicle) { - const noSelection = document.getElementById('noSelection'); - const vehicleDetails = document.getElementById('vehicleDetails'); - const spawnBtn = document.getElementById('spawnBtn'); - const storeBtn = document.getElementById('storeBtn'); - - if (noSelection) noSelection.style.display = 'none'; - if (vehicleDetails) vehicleDetails.style.display = 'flex'; - - // Update details - document.getElementById('detailIcon').textContent = vehicle.icon; - document.getElementById('detailName').textContent = vehicle.name; - document.getElementById('detailType').textContent = vehicle.type; - document.getElementById('detailStatus').textContent = vehicle.status; - document.getElementById('detailCondition').textContent = `${vehicle.condition}%`; - document.getElementById('detailFuel').textContent = `${vehicle.fuel}%`; - document.getElementById('detailLocation').textContent = vehicle.location; - document.getElementById('detailSeats').textContent = vehicle.seats; - document.getElementById('detailSpeed').textContent = vehicle.speed; - document.getElementById('detailCargo').textContent = vehicle.cargo; - - // Show/hide action buttons based on status - if (vehicle.status === 'stored') { - spawnBtn.style.display = 'flex'; - storeBtn.style.display = 'none'; - } else { - spawnBtn.style.display = 'none'; - storeBtn.style.display = 'flex'; - } -} - -// Spawn vehicle -function spawnVehicle(vehicle) { - console.log('Spawning vehicle:', vehicle.name); - - // Update local state - vehicle.status = 'active'; - vehicle.location = 'In Use'; - - sendEvent('garage::spawn', { - vehicleId: vehicle.id, - vehicleName: vehicle.name, - vehicleType: vehicle.type - }); - - // Re-render - renderVehicles(); - updateStats(); - if (selectedVehicle && selectedVehicle.id === vehicle.id) { - showVehicleDetails(vehicle); - } -} - -// Store vehicle -function storeVehicle(vehicle) { - console.log('Storing vehicle:', vehicle.name); - - // Update local state - vehicle.status = 'stored'; - vehicle.location = 'Garage A'; - - sendEvent('garage::store', { - vehicleId: vehicle.id, - vehicleName: vehicle.name, - vehicleType: vehicle.type - }); - - // Re-render - renderVehicles(); - updateStats(); - if (selectedVehicle && selectedVehicle.id === vehicle.id) { - showVehicleDetails(vehicle); - } -} - -// Update stats -function updateStats() { - const stored = mockData.vehicles.filter(v => v.status === 'stored').length; - const active = mockData.vehicles.filter(v => v.status === 'active').length; - const capacity = mockData.vehicles.length + 6; // Mock capacity - - document.getElementById('storedCount').textContent = stored; - document.getElementById('activeCount').textContent = active; - document.getElementById('capacityCount').textContent = capacity; -} - -// Update garage data from external source -function updateGarageData(data) { - if (data.vehicles) { - mockData.vehicles = data.vehicles; - renderVehicles(); - updateStats(); - - // Update selected vehicle if it still exists - if (selectedVehicle) { - const updated = mockData.vehicles.find(v => v.id === selectedVehicle.id); - if (updated) { - selectedVehicle = updated; - showVehicleDetails(updated); - } else { - selectedVehicle = null; - const noSelection = document.getElementById('noSelection'); - const vehicleDetails = document.getElementById('vehicleDetails'); - if (noSelection) noSelection.style.display = 'flex'; - if (vehicleDetails) vehicleDetails.style.display = 'none'; - } - } - } -} - -// Send event to Arma -function sendEvent(event, data) { - if (typeof A3API !== 'undefined') { - A3API.SendAlert(JSON.stringify({ - event: event, - data: data - })); - } else { - console.log('Event:', event, 'Data:', data); - } -} - -// Auto-initialize -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initGarage); -} else { - initGarage(); -} - -// Expose functions globally -window.updateGarageData = updateGarageData; -window.selectVehicle = selectVehicle; -window.spawnVehicle = spawnVehicle; -window.storeVehicle = storeVehicle; diff --git a/arma/client/addons/garage/ui/_site/style.css b/arma/client/addons/garage/ui/_site/style.css deleted file mode 100644 index a5c1ef7..0000000 --- a/arma/client/addons/garage/ui/_site/style.css +++ /dev/null @@ -1,605 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - height: 100vh; - width: 100vw; - background: rgba(0, 0, 0, 0.7); - font-family: Arial, sans-serif; - color: rgba(200, 220, 240, 0.95); - overflow: hidden; -} - -.garage-container { - height: 100vh; - width: 100vw; - padding: 2rem; - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -/* Header Section */ -.garage-header { - display: flex; - align-items: center; - gap: 1.5rem; - padding: 1.25rem 1.5rem; - background: rgba(15, 20, 30, 0.9); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - box-shadow: - 0 0 20px rgba(100, 150, 200, 0.15), - 0 4px 16px rgba(0, 0, 0, 0.8); -} - -.garage-logo { - width: 60px; - height: 60px; - background: rgba(20, 30, 45, 0.8); - border: 2px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; -} - -.logo-icon { - font-size: 2rem; -} - -.garage-info { - flex: 1; -} - -.garage-title { - font-size: 1.5rem; - font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; - color: rgba(200, 220, 255, 1); - margin-bottom: 0.25rem; -} - -.garage-subtitle { - font-size: 0.875rem; - color: rgba(140, 160, 180, 0.8); - letter-spacing: 0.5px; -} - -.garage-stats { - display: flex; - gap: 1.5rem; -} - -.stat-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.25rem; - padding: 0.75rem 1.25rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 4px; -} - -.stat-label { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(140, 160, 180, 0.8); -} - -.stat-value { - font-size: 1.25rem; - font-weight: 600; - color: rgba(100, 200, 150, 1); -} - -.header-actions { - display: flex; - gap: 0.75rem; -} - -.action-btn { - padding: 0.625rem 1.25rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.5px; - cursor: pointer; - transition: all 0.15s ease; -} - -.action-btn:hover { - background: rgba(30, 45, 70, 0.9); - border-color: rgba(150, 200, 255, 0.7); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.2), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.close-btn { - border-color: rgba(200, 100, 100, 0.4); -} - -.close-btn:hover { - border-color: rgba(255, 100, 100, 0.7); - box-shadow: - 0 0 15px rgba(200, 100, 100, 0.2), - inset 0 0 20px rgba(200, 100, 100, 0.05); -} - -/* Main Content */ -.garage-content { - flex: 1; - display: grid; - grid-template-columns: 250px 1fr 350px; - gap: 1.5rem; - overflow: hidden; -} - -/* Panels */ -.garage-panel { - background: rgba(15, 20, 30, 0.9); - border: 1px solid rgba(100, 150, 200, 0.4); - border-left: 3px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; - display: flex; - flex-direction: column; - box-shadow: - 0 0 20px rgba(100, 150, 200, 0.1), - 0 4px 16px rgba(0, 0, 0, 0.6); -} - -.panel-header { - padding: 1.25rem 1.5rem; - border-bottom: 1px solid rgba(100, 150, 200, 0.2); -} - -.panel-title { - font-size: 1rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(200, 220, 255, 1); -} - -.panel-content { - flex: 1; - padding: 1.5rem; - overflow-y: auto; -} - -/* Custom Scrollbar */ -.panel-content::-webkit-scrollbar { - width: 8px; -} - -.panel-content::-webkit-scrollbar-track { - background: rgba(15, 20, 30, 0.5); - border-radius: 4px; -} - -.panel-content::-webkit-scrollbar-thumb { - background: rgba(100, 150, 200, 0.3); - border-radius: 4px; -} - -.panel-content::-webkit-scrollbar-thumb:hover { - background: rgba(100, 150, 200, 0.5); -} - -/* Filters */ -.filter-section { - margin-bottom: 2rem; -} - -.filter-section:last-child { - margin-bottom: 0; -} - -.filter-title { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(160, 180, 200, 0.85); - margin-bottom: 0.75rem; -} - -.filter-buttons { - display: flex; - gap: 0.5rem; -} - -.filter-btn { - flex: 1; - padding: 0.625rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.5px; - cursor: pointer; - transition: all 0.15s ease; -} - -.filter-btn:hover { - background: rgba(30, 45, 70, 0.8); - border-color: rgba(150, 200, 255, 0.5); -} - -.filter-btn.active { - background: rgba(100, 150, 200, 0.2); - border-color: rgba(100, 150, 200, 0.6); - box-shadow: - 0 0 10px rgba(100, 150, 200, 0.15), - inset 0 0 15px rgba(100, 150, 200, 0.05); -} - -.type-list { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.type-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.875rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.3); - border-left: 3px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; - text-align: left; -} - -.type-item:hover { - background: rgba(30, 45, 70, 0.8); - border-left-color: rgba(150, 200, 255, 0.7); -} - -.type-item.active { - background: rgba(30, 45, 70, 0.9); - border-left-color: rgba(100, 200, 150, 0.8); - box-shadow: - 0 0 15px rgba(100, 200, 150, 0.15), - inset 0 0 20px rgba(100, 200, 150, 0.05); -} - -.type-icon { - font-size: 1.5rem; -} - -.type-name { - font-size: 0.875rem; - color: rgba(200, 220, 240, 0.95); -} - -.search-input { - width: 100%; - padding: 0.75rem 1rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 0.875rem; - transition: all 0.15s ease; -} - -.search-input:focus { - outline: none; - border-color: rgba(150, 200, 255, 0.6); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.15), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.search-input::placeholder { - color: rgba(100, 120, 140, 0.6); -} - -/* Vehicles Grid */ -.vehicles-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 1rem; -} - -.vehicle-card { - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-left: 3px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - padding: 1.25rem; - cursor: pointer; - transition: all 0.15s ease; - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.vehicle-card:hover { - background: rgba(30, 45, 70, 0.7); - border-left-color: rgba(150, 200, 255, 0.7); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.15), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.vehicle-card.selected { - background: rgba(30, 45, 70, 0.8); - border-left-color: rgba(100, 200, 150, 0.8); - box-shadow: - 0 0 20px rgba(100, 200, 150, 0.2), - inset 0 0 25px rgba(100, 200, 150, 0.05); -} - -.vehicle-icon { - font-size: 3rem; - text-align: center; -} - -.vehicle-name { - font-size: 0.95rem; - font-weight: 600; - color: rgba(200, 220, 255, 1); - text-align: center; -} - -.vehicle-type { - font-size: 0.75rem; - color: rgba(140, 160, 180, 0.85); - text-align: center; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.vehicle-status { - padding: 0.375rem; - background: rgba(100, 150, 200, 0.2); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 3px; - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.5px; - text-align: center; - font-weight: 600; -} - -.vehicle-status.stored { - background: rgba(100, 150, 200, 0.2); - border-color: rgba(100, 150, 200, 0.4); - color: rgba(150, 200, 255, 0.9); -} - -.vehicle-status.active { - background: rgba(100, 200, 150, 0.2); - border-color: rgba(100, 200, 150, 0.4); - color: rgba(150, 255, 200, 0.9); -} - -/* Vehicle Details */ -.no-selection { - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; - padding: 3rem 1rem; -} - -.no-selection-icon { - font-size: 4rem; - opacity: 0.3; -} - -.no-selection p { - font-size: 0.875rem; - color: rgba(140, 160, 180, 0.7); -} - -.vehicle-details { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.detail-header { - display: flex; - align-items: center; - gap: 1rem; - padding: 1.25rem; - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-left: 3px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; -} - -.detail-icon { - font-size: 3rem; -} - -.detail-info { - flex: 1; -} - -.detail-name { - font-size: 1.125rem; - font-weight: 600; - color: rgba(200, 220, 255, 1); - margin-bottom: 0.25rem; -} - -.detail-type { - font-size: 0.75rem; - color: rgba(140, 160, 180, 0.85); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.detail-stats { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.75rem; -} - -.detail-stat { - padding: 0.875rem; - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 4px; - display: flex; - flex-direction: column; - gap: 0.375rem; -} - -.detail-label { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(140, 160, 180, 0.85); -} - -.detail-value { - font-size: 0.95rem; - font-weight: 600; - color: rgba(200, 220, 240, 0.95); -} - -.detail-actions { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.detail-btn { - padding: 1rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; - gap: 0.75rem; - cursor: pointer; - transition: all 0.15s ease; -} - -.detail-btn:hover { - background: rgba(30, 45, 70, 0.9); - border-color: rgba(150, 200, 255, 0.7); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.2), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.spawn-btn { - background: rgba(100, 150, 200, 0.2); - border-color: rgba(100, 150, 200, 0.5); -} - -.spawn-btn:hover { - background: rgba(100, 150, 200, 0.3); - border-color: rgba(150, 200, 255, 0.7); -} - -.store-btn { - background: rgba(200, 150, 100, 0.2); - border-color: rgba(200, 150, 100, 0.4); -} - -.store-btn:hover { - background: rgba(200, 150, 100, 0.3); - border-color: rgba(255, 200, 150, 0.6); -} - -.btn-icon { - font-size: 1.25rem; -} - -.btn-text { - font-size: 0.875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(200, 220, 240, 0.95); -} - -.detail-specs { - padding: 1.25rem; - background: rgba(20, 30, 45, 0.5); - border: 1px solid rgba(100, 150, 200, 0.2); - border-radius: 4px; -} - -.specs-title { - font-size: 0.875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(180, 200, 220, 0.9); - margin-bottom: 1rem; -} - -.specs-list { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.spec-item { - display: flex; - justify-content: space-between; - align-items: center; - padding-bottom: 0.75rem; - border-bottom: 1px solid rgba(100, 150, 200, 0.15); -} - -.spec-item:last-child { - padding-bottom: 0; - border-bottom: none; -} - -.spec-label { - font-size: 0.8rem; - color: rgba(140, 160, 180, 0.85); -} - -.spec-value { - font-size: 0.875rem; - font-weight: 600; - color: rgba(200, 220, 240, 0.95); -} - -/* Responsive */ -@media (max-width: 1400px) { - .garage-content { - grid-template-columns: 220px 1fr 320px; - } - - .vehicles-grid { - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - } -} - -@media (max-width: 1200px) { - .garage-content { - grid-template-columns: 1fr 350px; - } - - .filters-panel { - display: none; - } -} diff --git a/arma/client/addons/garage/ui/src/bootstrap.js b/arma/client/addons/garage/ui/src/bootstrap.js new file mode 100644 index 0000000..f6b5949 --- /dev/null +++ b/arma/client/addons/garage/ui/src/bootstrap.js @@ -0,0 +1,19 @@ +(function () { + const ForgeWebUI = window.ForgeWebUI; + const GarageApp = window.GarageApp; + const app = ForgeWebUI.createApp({ + name: "garage", + root: "#app", + setup({ root }) { + ForgeWebUI.mount(root, () => GarageApp.components.App(), { + preserveScroll: true, + }); + + if (GarageApp.bridge) { + GarageApp.bridge.notifyReady(); + } + }, + }); + + app.start(); +})(); diff --git a/arma/client/addons/garage/ui/src/bridge.js b/arma/client/addons/garage/ui/src/bridge.js new file mode 100644 index 0000000..c86b282 --- /dev/null +++ b/arma/client/addons/garage/ui/src/bridge.js @@ -0,0 +1,87 @@ +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + const store = GarageApp.store; + const bridge = window.ForgeWebUI.createBridge({ + closeEvent: "garage::close", + globalName: "ForgeBridge", + readyEvent: "garage::ready", + }); + + function requestClose() { + return bridge.close({}); + } + + function requestRefresh() { + return bridge.send("garage::refresh", {}); + } + + function requestRetrieve(payload) { + return bridge.send("garage::vehicle::retrieve::request", payload); + } + + function requestStore(payload) { + return bridge.send("garage::vehicle::store::request", payload); + } + + function notifyReady() { + return bridge.ready({ loaded: true }); + } + + function hydrate(payloadData) { + GarageApp.data.applyHydratePayload(payloadData); + store.hydrateFromPayload(payloadData); + } + + bridge.on("garage::hydrate", hydrate); + bridge.on("garage::sync", hydrate); + + bridge.on("garage::retrieve::success", (payloadData) => { + store.finishAction(); + if (GarageApp.actions) { + GarageApp.actions.showNotice( + "success", + payloadData.message || "Vehicle retrieved from the garage.", + ); + } + }); + + bridge.on("garage::retrieve::failure", (payloadData) => { + store.finishAction(); + if (GarageApp.actions) { + GarageApp.actions.showNotice( + "error", + payloadData.message || "Unable to retrieve vehicle.", + ); + } + }); + + bridge.on("garage::store::success", (payloadData) => { + store.finishAction(); + if (GarageApp.actions) { + GarageApp.actions.showNotice( + "success", + payloadData.message || "Vehicle stored in the garage.", + ); + } + }); + + bridge.on("garage::store::failure", (payloadData) => { + store.finishAction(); + if (GarageApp.actions) { + GarageApp.actions.showNotice( + "error", + payloadData.message || "Unable to store vehicle.", + ); + } + }); + + GarageApp.bridge = { + notifyReady, + receive: bridge.receive, + requestClose, + requestRefresh, + requestRetrieve, + requestStore, + sendEvent: bridge.send, + }; +})(); diff --git a/arma/client/addons/garage/ui/src/components/AppShell.js b/arma/client/addons/garage/ui/src/components/AppShell.js new file mode 100644 index 0000000..6d00c24 --- /dev/null +++ b/arma/client/addons/garage/ui/src/components/AppShell.js @@ -0,0 +1,831 @@ +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + const { h } = GarageApp.runtime; + const WindowTitleBar = window.SharedUI.componentFns.WindowTitleBar; + const store = GarageApp.store; + const actions = GarageApp.actions; + const { categories, garage, nearby, session } = GarageApp.data; + + function q(query, values) { + const needle = String(query || "") + .trim() + .toLowerCase(); + if (!needle) { + return true; + } + + return values.some((value) => + String(value || "") + .toLowerCase() + .includes(needle), + ); + } + + function pct(value) { + return Math.max(0, Math.min(100, Math.round(Number(value || 0) * 100))); + } + + function categoryLabel(category) { + const match = categories.find( + (entry) => entry.id === String(category || "other").toLowerCase(), + ); + return match ? match.label : "Other"; + } + + function distanceLabel(value) { + return `${Math.round(Number(value || 0))} m`; + } + + function plateLabel(value) { + return String(value || "").trim() || "Untracked"; + } + + function statusLabel(vehicle) { + if (!vehicle) { + return "-"; + } + + if (vehicle.entryKind === "stored") { + return "Stored"; + } + + return vehicle.isEmpty === false ? "Crewed" : "Ready"; + } + + function normalizeHitPointLabel(value) { + return String(value || "") + .replace(/^Hit/i, "") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .trim(); + } + + function sameEntry(left, right) { + if (!left || !right) { + return false; + } + + return ( + String(left.entryKind || "") === String(right.entryKind || "") && + String(left.plate || "") === String(right.plate || "") && + String(left.netId || "") === String(right.netId || "") + ); + } + + function selectedEntry(state) { + if (state.selectedKind === "stored") { + return ( + (garage.vehicles || []).find( + (vehicle) => + String(vehicle.plate || "") === state.selectedId, + ) || null + ); + } + + if (state.selectedKind === "nearby") { + return ( + (nearby.vehicles || []).find( + (vehicle) => + String(vehicle.netId || "") === state.selectedId, + ) || null + ); + } + + return null; + } + + function visibleVehicles(vehicles, state) { + return (vehicles || []).filter((vehicle) => { + if ( + state.categoryFilter !== "all" && + String(vehicle.category || "").toLowerCase() !== + state.categoryFilter + ) { + return false; + } + + return q(state.searchQuery, [ + vehicle.displayName, + vehicle.classname, + vehicle.plate, + vehicle.netId, + vehicle.category, + ]); + }); + } + + function stat(label, value, tone = "") { + return h( + "div", + { + className: tone + ? `garage-stat-card is-${tone}` + : "garage-stat-card", + }, + h("span", { className: "garage-stat-label" }, label), + h("span", { className: "garage-stat-value" }, value), + ); + } + + function meter(label, percent, tone) { + return h( + "div", + { className: "garage-meter" }, + h( + "div", + { className: "garage-meter-label-row" }, + h("span", { className: "garage-meter-label" }, label), + h("span", { className: "garage-meter-value" }, `${percent}%`), + ), + h( + "div", + { className: "garage-meter-track" }, + h("span", { + className: `garage-meter-fill is-${tone}`, + style: { width: `${percent}%` }, + }), + ), + ); + } + + function vehicleItem(vehicle, currentSelection) { + const id = + vehicle.entryKind === "stored" + ? String(vehicle.plate || "") + : String(vehicle.netId || ""); + const isNearby = vehicle.entryKind === "nearby"; + + return h( + "button", + { + type: "button", + className: sameEntry(vehicle, currentSelection) + ? "garage-vehicle-item is-selected" + : "garage-vehicle-item", + onClick: () => actions.selectEntry(vehicle.entryKind, id), + }, + h( + "div", + { className: "garage-vehicle-item-head" }, + h( + "div", + { className: "garage-vehicle-copy" }, + h( + "span", + { className: "garage-vehicle-title" }, + vehicle.displayName || vehicle.classname || "Vehicle", + ), + h( + "span", + { className: "garage-vehicle-meta" }, + isNearby + ? `Nearby ${distanceLabel(vehicle.distance)}` + : `Plate ${plateLabel(vehicle.plate)}`, + ), + ), + h( + "span", + { + className: + isNearby && vehicle.isEmpty === false + ? "garage-badge is-warning" + : "garage-badge", + }, + isNearby + ? vehicle.isEmpty === false + ? "Crewed" + : "Empty" + : categoryLabel(vehicle.category), + ), + ), + h( + "div", + { className: "garage-inline-meters" }, + meter("Health", pct(vehicle.health), "health"), + meter("Fuel", pct(vehicle.fuel), "fuel"), + ), + ); + } + + function vehicleList(title, eyebrow, scrollId, vehicles, currentSelection) { + return h( + "section", + { className: "garage-card garage-list-card" }, + h( + "div", + { className: "garage-card-header" }, + h( + "div", + null, + h("span", { className: "garage-eyebrow" }, eyebrow), + h("h2", { className: "garage-section-title" }, title), + ), + h( + "span", + { className: "garage-pill" }, + `${vehicles.length} ${vehicles.length === 1 ? "Vehicle" : "Vehicles"}`, + ), + ), + h( + "div", + { + className: "garage-card-body garage-scroll-body", + "data-preserve-scroll-id": scrollId, + }, + vehicles.length > 0 + ? vehicles.map((vehicle) => + vehicleItem(vehicle, currentSelection), + ) + : h( + "div", + { className: "garage-empty-state" }, + h( + "h3", + { className: "garage-empty-title" }, + "No matching vehicles", + ), + h( + "p", + { className: "garage-empty-copy" }, + "Adjust the current search or category filter to view more records.", + ), + ), + ), + ); + } + + function hitPointRows(hitPoints) { + const rows = (Array.isArray(hitPoints) ? hitPoints : []) + .slice() + .sort( + (left, right) => + Number(right.value || 0) - Number(left.value || 0), + ) + .slice(0, 6) + .filter((row) => Number(row.value || 0) > 0); + + if (rows.length === 0) { + return h( + "div", + { className: "garage-empty-inline" }, + "No subsystem damage reported.", + ); + } + + return h( + "div", + { className: "garage-hitpoint-grid" }, + rows.map((row) => + h( + "div", + { className: "garage-hitpoint-row" }, + h( + "div", + { className: "garage-hitpoint-copy" }, + h( + "span", + { className: "garage-hitpoint-name" }, + normalizeHitPointLabel(row.name) || "Subsystem", + ), + row.selection + ? h( + "span", + { className: "garage-hitpoint-selection" }, + row.selection, + ) + : null, + ), + h( + "span", + { className: "garage-hitpoint-value" }, + `${Math.round(Number(row.value || 0) * 100)}%`, + ), + ), + ), + ); + } + + function detailPanel(currentSelection, state) { + if (!currentSelection) { + return h( + "section", + { className: "garage-card garage-detail-card" }, + h( + "div", + { className: "garage-card-header" }, + h( + "div", + null, + h("span", { className: "garage-eyebrow" }, "Selection"), + h( + "h2", + { className: "garage-section-title" }, + "Vehicle Detail", + ), + ), + ), + h( + "div", + { className: "garage-card-body garage-detail-empty" }, + h( + "h3", + { className: "garage-empty-title" }, + "Select a vehicle", + ), + h( + "p", + { className: "garage-empty-copy" }, + "Choose a stored record to retrieve or a nearby vehicle to store.", + ), + ), + ); + } + + const isStored = currentSelection.entryKind === "stored"; + const pendingAction = String(state.pendingAction || ""); + const isBusy = + pendingAction === "retrieve" || pendingAction === "store"; + const canRetrieve = isStored && !session.spawnBlocked && !isBusy; + const canStore = + !isStored && currentSelection.isEmpty !== false && !isBusy; + + return h( + "section", + { className: "garage-card garage-detail-card" }, + h( + "div", + { className: "garage-card-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + isStored ? "Stored Record" : "Nearby Vehicle", + ), + h( + "h2", + { className: "garage-section-title" }, + currentSelection.displayName || + currentSelection.classname || + "Vehicle", + ), + ), + h( + "span", + { + className: + currentSelection.entryKind === "nearby" && + currentSelection.isEmpty === false + ? "garage-badge is-warning" + : "garage-badge", + }, + isStored + ? `Plate ${plateLabel(currentSelection.plate)}` + : currentSelection.isEmpty === false + ? "Crewed" + : "Ready", + ), + ), + h( + "div", + { className: "garage-card-body garage-detail-body" }, + h( + "div", + { className: "garage-detail-grid" }, + h( + "div", + { className: "garage-detail-copy" }, + h( + "div", + { className: "garage-detail-meta" }, + stat( + "Category", + categoryLabel(currentSelection.category), + ), + stat( + "Status", + statusLabel(currentSelection), + currentSelection.entryKind === "nearby" && + currentSelection.isEmpty === false + ? "danger" + : "", + ), + stat( + isStored ? "Record" : "Distance", + isStored + ? plateLabel(currentSelection.plate) + : distanceLabel(currentSelection.distance), + isStored ? "" : "accent", + ), + ), + h( + "div", + { className: "garage-meter-stack" }, + meter( + "Health", + pct(currentSelection.health), + "health", + ), + meter("Fuel", pct(currentSelection.fuel), "fuel"), + ), + h( + "div", + { className: "garage-action-row" }, + isStored + ? h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-primary", + disabled: !canRetrieve, + onClick: () => + actions.requestRetrieveSelected(), + }, + pendingAction === "retrieve" + ? "Retrieving..." + : "Retrieve Vehicle", + ) + : h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-primary", + disabled: !canStore, + onClick: () => + actions.requestStoreSelected(), + }, + pendingAction === "store" + ? "Storing..." + : "Store Vehicle", + ), + h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-secondary", + disabled: isBusy, + onClick: () => actions.refreshGarage(), + }, + "Refresh", + ), + ), + h( + "p", + { className: "garage-detail-note" }, + isStored + ? session.spawnBlocked + ? "The garage spawn lane is currently blocked." + : "Retrieve this stored vehicle into the active spawn lane." + : currentSelection.isEmpty === false + ? "Only empty nearby vehicles can be stored." + : "Store this nearby vehicle back into persistent garage storage.", + ), + ), + h( + "div", + { className: "garage-detail-subsystems" }, + h( + "div", + { className: "garage-subsystem-header" }, + h( + "span", + { className: "garage-eyebrow" }, + "Subsystems", + ), + h( + "span", + { className: "garage-detail-caption" }, + "Highest damage first", + ), + ), + hitPointRows(currentSelection.hitPoints), + ), + ), + ), + ); + } + + GarageApp.components = GarageApp.components || {}; + GarageApp.components.App = function App() { + const state = { + categoryFilter: store.getCategoryFilter(), + notice: store.getNotice(), + pendingAction: store.getPendingAction(), + searchQuery: store.getSearchQuery(), + selectedId: store.getSelectedId(), + selectedKind: store.getSelectedKind(), + }; + const currentSelection = selectedEntry(state); + const storedVehicles = visibleVehicles(garage.vehicles || [], state); + const nearbyVehicles = visibleVehicles(nearby.vehicles || [], state); + const searchLabel = state.searchQuery + ? `Search: ${state.searchQuery}` + : "Live"; + + return h( + "div", + { className: "garage-shell" }, + WindowTitleBar({ + kicker: "FORGE Logistics", + title: "Vehicle Garage", + onClose: () => actions.closeGarage(), + closeLabel: "Close garage interface", + }), + state.notice.text + ? h( + "div", + { className: "garage-toast-stack" }, + h( + "div", + { + className: + state.notice.type === "error" + ? "garage-toast is-error" + : "garage-toast is-success", + }, + state.notice.text, + ), + ) + : null, + h( + "div", + { className: "garage-layout" }, + h( + "aside", + { className: "garage-sidebar" }, + h( + "section", + { className: "garage-module" }, + h( + "div", + { className: "garage-module-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + "Search", + ), + h( + "h2", + { className: "garage-section-title" }, + "Vehicle Records", + ), + ), + h( + "span", + { className: "garage-pill" }, + searchLabel, + ), + ), + h( + "div", + { className: "garage-search-form" }, + h("input", { + id: "garage-search-input", + type: "text", + className: "garage-search-input", + placeholder: + "Search by name, plate, or category", + value: state.searchQuery, + }), + h( + "div", + { className: "garage-search-actions" }, + h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-primary", + onClick: () => + actions.applySearchQuery( + document.getElementById( + "garage-search-input", + )?.value || "", + ), + }, + "Apply Search", + ), + h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-secondary", + onClick: () => actions.clearSearch(), + }, + "Clear", + ), + ), + ), + ), + h( + "section", + { className: "garage-module" }, + h( + "div", + { className: "garage-module-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + "Filter", + ), + h( + "h2", + { className: "garage-section-title" }, + "Vehicle Categories", + ), + ), + ), + h( + "div", + { className: "garage-category-grid" }, + categories.map((category) => + h( + "button", + { + type: "button", + className: + state.categoryFilter === category.id + ? "garage-chip is-active" + : "garage-chip", + onClick: () => + actions.selectCategory(category.id), + }, + category.label, + ), + ), + ), + ), + h( + "section", + { className: "garage-module" }, + h( + "div", + { className: "garage-module-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + "Status", + ), + h( + "h2", + { className: "garage-section-title" }, + "Garage Summary", + ), + ), + h( + "button", + { + type: "button", + className: + "garage-btn garage-btn-secondary", + disabled: Boolean(state.pendingAction), + onClick: () => actions.refreshGarage(), + }, + "Refresh", + ), + ), + h( + "div", + { className: "garage-summary-grid" }, + stat( + "Stored", + `${session.capacityUsed}/${session.capacityMax}`, + ), + stat("Nearby", session.nearbyCount, "accent"), + stat( + "Spawn Lane", + session.spawnStatus, + session.spawnBlocked ? "danger" : "", + ), + ), + ), + ), + h( + "main", + { className: "garage-main" }, + h( + "section", + { className: "garage-panel" }, + h( + "div", + { className: "garage-panel-header" }, + h( + "div", + null, + h( + "span", + { className: "garage-eyebrow" }, + "Operations Bay", + ), + h( + "h1", + { className: "garage-title" }, + session.garageName || "Vehicle Garage", + ), + ), + h( + "span", + { className: "garage-pill" }, + `${session.capacityUsed}/${session.capacityMax} Stored`, + ), + ), + h( + "div", + { className: "garage-panel-intro" }, + h( + "p", + { className: "garage-copy" }, + "Retrieve stored vehicles into the active spawn lane or store nearby empty vehicles back into persistent ownership records.", + ), + ), + h( + "div", + { className: "garage-dashboard" }, + vehicleList( + "Stored Vehicles", + "Persistent Records", + "garage-stored-list", + storedVehicles, + currentSelection, + ), + vehicleList( + "Nearby Vehicles", + "Store Window", + "garage-nearby-list", + nearbyVehicles, + currentSelection, + ), + detailPanel(currentSelection, state), + ), + ), + ), + ), + h( + "footer", + { className: "garage-footer-bar" }, + h( + "div", + { className: "garage-footer" }, + h( + "div", + { className: "garage-footer-block" }, + h( + "span", + { className: "garage-footer-title" }, + "Storage Capacity", + ), + h( + "span", + { className: "garage-footer-copy" }, + `${session.capacityUsed} of ${session.capacityMax} vehicle slot(s) are currently occupied.`, + ), + ), + h( + "div", + { className: "garage-footer-block" }, + h( + "span", + { className: "garage-footer-title" }, + "Retrieval Window", + ), + h( + "span", + { className: "garage-footer-copy" }, + session.spawnBlocked + ? "Spawn lane is blocked. Clear the bay before retrieving another vehicle." + : "Spawn lane is clear. Stored vehicles can be retrieved immediately.", + ), + ), + h( + "div", + { className: "garage-footer-block" }, + h( + "span", + { className: "garage-footer-title" }, + "Store Rules", + ), + h( + "span", + { className: "garage-footer-copy" }, + "Only nearby empty vehicles can be stored. Nearby count updates from the live world state.", + ), + ), + ), + ), + ); + }; +})(); diff --git a/arma/client/addons/garage/ui/src/data.js b/arma/client/addons/garage/ui/src/data.js new file mode 100644 index 0000000..deca479 --- /dev/null +++ b/arma/client/addons/garage/ui/src/data.js @@ -0,0 +1,57 @@ +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + + const defaultSession = { + garageName: "Vehicle Garage", + capacityUsed: 0, + capacityMax: 5, + nearbyCount: 0, + spawnBlocked: false, + spawnStatus: "Ready", + }; + + const defaultGarage = { + vehicles: [], + }; + + const defaultNearby = { + vehicles: [], + }; + + function cloneValue(value) { + return JSON.parse(JSON.stringify(value)); + } + + function replaceObject(target, source) { + Object.keys(target).forEach((key) => delete target[key]); + Object.assign(target, cloneValue(source)); + } + + GarageApp.data = { + categories: [ + { id: "all", label: "All" }, + { id: "car", label: "Cars" }, + { id: "armor", label: "Armor" }, + { id: "air", label: "Air" }, + { id: "naval", label: "Naval" }, + { id: "other", label: "Other" }, + ], + session: Object.assign({}, defaultSession), + garage: Object.assign({}, defaultGarage), + nearby: Object.assign({}, defaultNearby), + applyHydratePayload(payload) { + replaceObject( + this.session, + Object.assign({}, defaultSession, payload?.session || {}), + ); + replaceObject( + this.garage, + Object.assign({}, defaultGarage, payload?.garage || {}), + ); + replaceObject( + this.nearby, + Object.assign({}, defaultNearby, payload?.nearby || {}), + ); + }, + }; +})(); diff --git a/arma/client/addons/garage/ui/src/registry/events.js b/arma/client/addons/garage/ui/src/registry/events.js new file mode 100644 index 0000000..3ca41d3 --- /dev/null +++ b/arma/client/addons/garage/ui/src/registry/events.js @@ -0,0 +1,174 @@ +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + const store = GarageApp.store; + + let noticeTimer = null; + + function getStoredVehicles() { + return Array.isArray(GarageApp.data?.garage?.vehicles) + ? GarageApp.data.garage.vehicles + : []; + } + + function getNearbyVehicles() { + return Array.isArray(GarageApp.data?.nearby?.vehicles) + ? GarageApp.data.nearby.vehicles + : []; + } + + function getSelectedEntry() { + const selection = store.getSelection(); + if (selection.kind === "stored") { + return ( + getStoredVehicles().find( + (vehicle) => String(vehicle.plate || "") === selection.id, + ) || null + ); + } + + if (selection.kind === "nearby") { + return ( + getNearbyVehicles().find( + (vehicle) => String(vehicle.netId || "") === selection.id, + ) || null + ); + } + + return null; + } + + function showNotice(type, text) { + store.setNotice({ type, text }); + + if (noticeTimer) { + clearTimeout(noticeTimer); + } + + noticeTimer = setTimeout(() => { + store.setNotice({ type: "", text: "" }); + noticeTimer = null; + }, 3200); + } + + function closeGarage() { + const bridge = GarageApp.bridge; + if (bridge && typeof bridge.requestClose === "function") { + const sent = bridge.requestClose(); + if (sent) { + return true; + } + } + + showNotice("error", "Garage bridge is unavailable."); + return false; + } + + function refreshGarage() { + const bridge = GarageApp.bridge; + if (bridge && typeof bridge.requestRefresh === "function") { + const sent = bridge.requestRefresh(); + if (sent) { + return true; + } + } + + showNotice("error", "Garage refresh bridge is unavailable."); + return false; + } + + function applySearchQuery(value) { + store.setSearchQuery(String(value || "").trim()); + } + + function clearSearch() { + store.setSearchQuery(""); + } + + function selectCategory(categoryId) { + store.setCategoryFilter(String(categoryId || "all").trim() || "all"); + } + + function selectEntry(kind, id) { + store.select(kind, id); + } + + function requestRetrieveSelected() { + const selectedEntry = getSelectedEntry(); + if (!selectedEntry || selectedEntry.entryKind !== "stored") { + showNotice("error", "Select a stored vehicle to retrieve."); + return false; + } + + if (GarageApp.data?.session?.spawnBlocked) { + showNotice("error", "The garage spawn area is blocked."); + return false; + } + + const bridge = GarageApp.bridge; + if (!bridge || typeof bridge.requestRetrieve !== "function") { + showNotice("error", "Garage retrieve bridge is unavailable."); + return false; + } + + store.startAction("retrieve"); + const sent = bridge.requestRetrieve({ + plate: selectedEntry.plate || "", + }); + + if (!sent) { + store.finishAction(); + showNotice("error", "Garage retrieve bridge is unavailable."); + return false; + } + + return true; + } + + function requestStoreSelected() { + const selectedEntry = getSelectedEntry(); + if (!selectedEntry || selectedEntry.entryKind !== "nearby") { + showNotice("error", "Select a nearby vehicle to store."); + return false; + } + + if (selectedEntry.isEmpty === false) { + showNotice( + "error", + "All crew must exit the vehicle before storing it.", + ); + return false; + } + + const bridge = GarageApp.bridge; + if (!bridge || typeof bridge.requestStore !== "function") { + showNotice("error", "Garage store bridge is unavailable."); + return false; + } + + store.startAction("store"); + const sent = bridge.requestStore({ + netId: selectedEntry.netId || "", + }); + + if (!sent) { + store.finishAction(); + showNotice("error", "Garage store bridge is unavailable."); + return false; + } + + return true; + } + + GarageApp.actions = { + showNotice, + closeGarage, + refreshGarage, + applySearchQuery, + clearSearch, + selectCategory, + selectEntry, + getSelectedEntry, + requestRetrieveSelected, + requestStoreSelected, + }; +})(); diff --git a/arma/client/addons/garage/ui/src/registry/store.js b/arma/client/addons/garage/ui/src/registry/store.js new file mode 100644 index 0000000..776c5bd --- /dev/null +++ b/arma/client/addons/garage/ui/src/registry/store.js @@ -0,0 +1,113 @@ +(function () { + const GarageApp = (window.GarageApp = window.GarageApp || {}); + const { createSignal } = GarageApp.runtime; + + class GarageStore { + constructor() { + [this.getSelectedKind, this.setSelectedKind] = createSignal(""); + [this.getSelectedId, this.setSelectedId] = createSignal(""); + [this.getSearchQuery, this.setSearchQuery] = createSignal(""); + [this.getCategoryFilter, this.setCategoryFilter] = + createSignal("all"); + [this.getPendingAction, this.setPendingAction] = createSignal(""); + [this.getNotice, this.setNotice] = createSignal({ + type: "", + text: "", + }); + } + + getSelection() { + return { + id: this.getSelectedId(), + kind: this.getSelectedKind(), + }; + } + + clearSelection() { + this.setSelectedKind(""); + this.setSelectedId(""); + } + + select(kind, id) { + this.setSelectedKind(String(kind || "")); + this.setSelectedId(String(id || "")); + } + + startAction(action) { + this.setPendingAction(String(action || "")); + } + + finishAction() { + this.setPendingAction(""); + } + + matchesSelection(entry) { + if (!entry || typeof entry !== "object") { + return false; + } + + const selection = this.getSelection(); + if (!selection.kind || !selection.id) { + return false; + } + + if (selection.kind === "stored") { + return ( + entry.entryKind === "stored" && + String(entry.plate || "") === selection.id + ); + } + + if (selection.kind === "nearby") { + return ( + entry.entryKind === "nearby" && + String(entry.netId || "") === selection.id + ); + } + + return false; + } + + ensureSelection() { + const garageVehicles = Array.isArray( + GarageApp.data?.garage?.vehicles, + ) + ? GarageApp.data.garage.vehicles + : []; + const nearbyVehicles = Array.isArray( + GarageApp.data?.nearby?.vehicles, + ) + ? GarageApp.data.nearby.vehicles + : []; + const hasCurrentSelection = [ + ...garageVehicles, + ...nearbyVehicles, + ].some((entry) => this.matchesSelection(entry)); + + if (hasCurrentSelection) { + return; + } + + const firstStored = garageVehicles[0] || null; + if (firstStored) { + this.select("stored", firstStored.plate || ""); + return; + } + + const firstNearby = nearbyVehicles[0] || null; + if (firstNearby) { + this.select("nearby", firstNearby.netId || ""); + return; + } + + this.clearSelection(); + } + + hydrateFromPayload() { + this.finishAction(); + this.ensureSelection(); + } + } + + GarageApp.store = new GarageStore(); +})(); diff --git a/arma/client/addons/garage/ui/src/runtime.js b/arma/client/addons/garage/ui/src/runtime.js new file mode 100644 index 0000000..f6daa1d --- /dev/null +++ b/arma/client/addons/garage/ui/src/runtime.js @@ -0,0 +1,7 @@ +(function () { + const runtime = window.ForgeWebUI; + const GarageApp = (window.GarageApp = window.GarageApp || {}); + + GarageApp.runtime = runtime; + window.AppRuntime = runtime; +})(); diff --git a/arma/client/addons/garage/ui/src/styles.css b/arma/client/addons/garage/ui/src/styles.css new file mode 100644 index 0000000..b45dec7 --- /dev/null +++ b/arma/client/addons/garage/ui/src/styles.css @@ -0,0 +1,579 @@ +:root { + --garage-shell-bg: #e4e3df; + --garage-surface: #f5f3ef; + --garage-surface-alt: #ece8e2; + --garage-border: rgba(74, 91, 110, 0.2); + --garage-border-strong: rgba(20, 46, 79, 0.18); + --garage-text-main: #1f2d3d; + --garage-text-muted: #6a7787; + --garage-text-subtle: #8792a0; + --garage-accent: #12365d; + --garage-accent-soft: #dbe7f3; + --garage-accent-line: rgba(18, 54, 93, 0.12); + --garage-warning: #8f5f26; +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +body { + font-family: "Segoe UI", "Trebuchet MS", sans-serif; + color: var(--garage-text-main); + background: var(--garage-shell-bg); +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.72; +} + +:focus-visible { + outline: 2px solid rgb(18 54 93 / 0.35); + outline-offset: 2px; +} + +#app { + width: 100%; + height: 100%; +} + +.garage-shell { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; + background: var(--garage-shell-bg); +} + +.garage-layout { + flex: 1; + min-height: 0; + width: min(100%, 1613px); + margin: 0 auto; + padding: 1.25rem; + display: grid; + grid-template-columns: 308px minmax(0, 1fr); + gap: 1.25rem; +} + +.garage-sidebar, +.garage-main { + min-height: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.garage-main { + overflow: hidden; +} + +.garage-module, +.garage-panel, +.garage-card { + background: linear-gradient( + 180deg, + var(--garage-surface) 0%, + var(--garage-surface-alt) 100% + ); + border: 1px solid var(--garage-border); + border-radius: 1.35rem; +} + +.garage-module, +.garage-card { + padding: 1rem; +} + +.garage-module { + display: grid; + gap: 0.85rem; + align-content: start; +} + +.garage-panel { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.garage-panel-header, +.garage-module-header, +.garage-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.garage-panel-header { + padding: 1rem 1rem 0; +} + +.garage-module-header { + align-items: flex-start; +} + +.garage-panel-intro { + padding: 0 1rem 1rem; + border-bottom: 1px solid var(--garage-accent-line); +} + +.garage-dashboard { + flex: 1; + min-height: 0; + padding: 1rem; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 1rem; + align-items: stretch; +} + +.garage-list-card, +.garage-detail-card { + min-height: 0; + display: flex; + flex-direction: column; +} + +.garage-detail-card { + grid-column: 1 / -1; +} + +.garage-scroll-body { + flex: 1; + min-height: 20rem; + max-height: 24rem; + overflow: auto; + display: grid; + gap: 0.8rem; + padding-right: 0.2rem; +} + +.garage-detail-body { + padding-top: 0.95rem; +} + +.garage-detail-grid { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.85fr); + gap: 1rem; +} + +.garage-detail-meta, +.garage-summary-grid, +.garage-search-actions, +.garage-category-grid, +.garage-action-row, +.garage-inline-meters, +.garage-hitpoint-grid, +.garage-footer { + display: grid; + gap: 0.75rem; +} + +.garage-detail-meta { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-bottom: 1rem; +} + +.garage-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.garage-summary-grid > :last-child { + grid-column: 1 / -1; +} + +.garage-search-actions, +.garage-action-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; +} + +.garage-category-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.65rem; +} + +.garage-footer-bar { + width: 100%; + border-top: 1px solid rgb(18 54 93 / 0.1); +} + +.garage-footer { + width: min(100%, 1613px); + margin: 0 auto; + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding: 0.95rem 1.25rem 1.15rem; +} + +.garage-meter-stack { + display: grid; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.garage-eyebrow, +.garage-footer-title, +.garage-stat-label, +.garage-meter-label, +.garage-hitpoint-selection { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--garage-text-subtle); +} + +.garage-title, +.garage-section-title { + margin: 0.16rem 0 0; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--garage-text-main); +} + +.garage-title { + font-size: 1.1rem; +} + +.garage-section-title { + font-size: 1.05rem; +} + +.garage-copy, +.garage-detail-note, +.garage-empty-copy, +.garage-footer-copy, +.garage-vehicle-meta, +.garage-detail-caption { + margin: 0; + font-size: 0.92rem; + line-height: 1.48; + color: var(--garage-text-muted); +} + +.garage-pill, +.garage-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.48rem 0.8rem; + border-radius: 999px; + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + background: var(--garage-accent-soft); + color: var(--garage-accent); +} + +.garage-badge.is-warning { + background: rgb(246 226 193 / 0.88); + color: var(--garage-warning); +} + +.garage-search-form { + display: grid; + gap: 0.75rem; +} + +.garage-search-input { + width: 100%; + height: 2.9rem; + padding: 0 0.95rem; + border-radius: 0.8rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.75); + color: var(--garage-text-main); +} + +.garage-stat-card { + min-width: 0; + padding: 0.85rem; + border-radius: 0.85rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.48); + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.garage-stat-card.is-accent { + background: linear-gradient( + 180deg, + rgb(237 243 249 / 0.92) 0%, + rgb(223 232 242 / 0.72) 100% + ); +} + +.garage-stat-card.is-danger { + background: linear-gradient( + 180deg, + rgb(254 242 242 / 0.95) 0%, + rgb(252 225 225 / 0.82) 100% + ); + border-color: rgb(220 151 151 / 0.38); +} + +.garage-stat-value { + font-size: 1rem; + font-weight: 700; + color: var(--garage-text-main); + line-height: 1.3; + overflow-wrap: anywhere; + word-break: break-word; +} + +.garage-chip { + min-height: 2.6rem; + padding: 0.68rem 0.9rem; + border-radius: 0.85rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.52); + color: var(--garage-text-muted); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.garage-chip.is-active { + background: var(--garage-accent-soft); + color: var(--garage-accent); + border-color: rgb(18 54 93 / 0.2); +} + +.garage-vehicle-item { + width: 100%; + padding: 0.9rem; + border-radius: 0.95rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.48); + color: inherit; + text-align: left; +} + +.garage-vehicle-item.is-selected { + border-color: rgb(18 54 93 / 0.24); + background: linear-gradient( + 180deg, + rgb(237 243 249 / 0.96) 0%, + rgb(223 232 242 / 0.74) 100% + ); + box-shadow: 0 16px 26px rgb(18 54 93 / 0.08); +} + +.garage-vehicle-item-head, +.garage-meter-label-row, +.garage-subsystem-header, +.garage-hitpoint-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.garage-vehicle-copy, +.garage-hitpoint-copy, +.garage-footer-block { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.18rem; +} + +.garage-vehicle-title, +.garage-hitpoint-name, +.garage-hitpoint-value { + font-size: 0.9rem; + font-weight: 700; + color: var(--garage-text-main); +} + +.garage-meter { + display: grid; + gap: 0.32rem; +} + +.garage-meter-track { + width: 100%; + height: 0.45rem; + overflow: hidden; + border-radius: 999px; + background: rgb(18 54 93 / 0.08); +} + +.garage-meter-value { + font-size: 0.78rem; + font-weight: 700; + color: var(--garage-text-main); +} + +.garage-meter-fill { + display: block; + height: 100%; + border-radius: inherit; +} + +.garage-meter-fill.is-health { + background: linear-gradient(90deg, #2f7d5b 0%, #4eaa82 100%); +} + +.garage-meter-fill.is-fuel { + background: linear-gradient(90deg, #12365d 0%, #3c6792 100%); +} + +.garage-btn { + min-height: 2.75rem; + padding: 0.72rem 1rem; + border-radius: 0.8rem; + border: 1px solid var(--garage-border-strong); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.garage-btn-primary { + background: rgb(255 255 255 / 0.68); + color: var(--garage-accent); +} + +.garage-btn-primary:hover { + background: rgb(219 231 243 / 0.88); +} + +.garage-btn-secondary { + background: rgb(255 255 255 / 0.42); + color: var(--garage-text-muted); +} + +.garage-btn-secondary:hover { + background: rgb(255 255 255 / 0.6); + color: var(--garage-text-main); +} + +.garage-hitpoint-row { + padding: 0.72rem 0.78rem; + border-radius: 0.85rem; + border: 1px solid var(--garage-border); + background: rgb(255 255 255 / 0.52); +} + +.garage-detail-empty, +.garage-empty-state { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + min-height: 100%; +} + +.garage-empty-title { + margin: 0 0 0.35rem; + font-size: 1rem; + font-weight: 700; + color: var(--garage-text-main); +} + +.garage-empty-inline { + padding: 0.9rem; + border-radius: 0.85rem; + border: 1px dashed var(--garage-border); + color: var(--garage-text-muted); + background: rgb(255 255 255 / 0.36); +} + +.garage-toast-stack { + position: fixed; + top: 1.2rem; + right: 1.5rem; + z-index: 10; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.garage-toast { + max-width: 24rem; + padding: 0.85rem 1rem; + border-radius: 0.9rem; + border: 1px solid var(--garage-border); + background: #fff; + box-shadow: 0 14px 28px rgb(16 34 56 / 0.14); + font-size: 0.92rem; +} + +.garage-toast.is-success { + background: #ecfdf5; + border-color: #bbf7d0; + color: #166534; +} + +.garage-toast.is-error { + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; +} + +@media (max-width: 1440px) { + .garage-layout { + grid-template-columns: 288px minmax(0, 1fr); + } + + .garage-detail-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1120px) { + .garage-layout { + grid-template-columns: 1fr; + overflow: auto; + } + + .garage-main, + .garage-sidebar { + min-height: auto; + } + + .garage-dashboard { + grid-template-columns: 1fr; + } + + .garage-detail-card { + grid-column: auto; + } + + .garage-scroll-body { + max-height: none; + min-height: 16rem; + } + + .garage-footer { + grid-template-columns: 1fr; + } +} diff --git a/arma/client/addons/garage/ui/ui.config.mjs b/arma/client/addons/garage/ui/ui.config.mjs new file mode 100644 index 0000000..bd3a713 --- /dev/null +++ b/arma/client/addons/garage/ui/ui.config.mjs @@ -0,0 +1,33 @@ +export default { + addonName: "garage", + title: "FORGE Vehicle Garage", + logLabel: "Garage UI", + outputDir: "_site", + jsBundles: [ + { + name: "Garage UI app", + output: "garage-ui.js", + sources: [ + "src/runtime.js", + "src/data.js", + "src/registry/store.js", + "src/bridge.js", + "src/registry/events.js", + "src/components/AppShell.js", + "src/bootstrap.js", + ], + }, + ], + cssBundles: [ + { + name: "Garage UI styles", + output: "garage-ui.css", + sources: ["src/styles.css"], + }, + ], + site: { + styles: ["garage-ui.css"], + commonScripts: ["forge-webui.js"], + scripts: ["garage-ui.js"], + }, +}; diff --git a/arma/client/addons/locker/README.md b/arma/client/addons/locker/README.md index f23b308..ccd333d 100644 --- a/arma/client/addons/locker/README.md +++ b/arma/client/addons/locker/README.md @@ -1,4 +1,3 @@ -forge_client_locker -=================== +# forge_client_locker Description for this addon diff --git a/arma/client/addons/locker/XEH_postInit.sqf b/arma/client/addons/locker/XEH_postInit.sqf index 421c54b..ba2da81 100644 --- a/arma/client/addons/locker/XEH_postInit.sqf +++ b/arma/client/addons/locker/XEH_postInit.sqf @@ -1 +1,8 @@ #include "script_component.hpp" + +["ace_arsenal_displayOpened", { + disableSerialization; + params ["_display"]; + _display displayAddEventHandler ["KeyDown", "_this select 3"]; + { (_display displayCtrl _x) ctrlShow false } forEach [1002, 1003, 1004, 1005, 1006]; +}] call CFUNC(addEventHandler); diff --git a/arma/client/addons/locker/XEH_postInitClient.sqf b/arma/client/addons/locker/XEH_postInitClient.sqf index 9e97a3b..d4baf95 100644 --- a/arma/client/addons/locker/XEH_postInitClient.sqf +++ b/arma/client/addons/locker/XEH_postInitClient.sqf @@ -1,7 +1,7 @@ #include "script_component.hpp" -if (isNil QGVAR(LockerClass)) then { [] call FUNC(initLockerClass); }; -if (isNil QGVAR(VArsenalClass)) then { [] call FUNC(initVAClass); }; +if (isNil QGVAR(LockerClass)) then { call FUNC(initLockerClass); }; +if (isNil QGVAR(VAClass)) then { call FUNC(initVAClass); }; [QGVAR(initLocker), { GVAR(LockerClass) call ["init", []]; @@ -10,7 +10,7 @@ if (isNil QGVAR(VArsenalClass)) then { [] call FUNC(initVAClass); }; [QGVAR(responseInitLocker), { params [["_data", createHashMap, [createHashMap]]]; - GVAR(LockerClass) call ["sync", [_data, true]]; + GVAR(LockerClass) call ["sync", [_data]]; }] call CFUNC(addEventHandler); [QGVAR(responseSyncLocker), { @@ -20,19 +20,19 @@ if (isNil QGVAR(VArsenalClass)) then { [] call FUNC(initVAClass); }; }] call CFUNC(addEventHandler); [QGVAR(initVA), { - GVAR(VArsenalClass) call ["init", []]; + GVAR(VAClass) call ["init", []]; }] call CFUNC(addEventHandler); [QGVAR(responseInitVA), { params [["_data", createHashMap, [createHashMap]]]; - GVAR(VArsenalClass) call ["sync", [_data, true]]; + GVAR(VAClass) call ["sync", [_data]]; }] call CFUNC(addEventHandler); [QGVAR(responseSyncVA), { params [["_data", createHashMap, [createHashMap, []]], ["_jip", false, [false]]]; - GVAR(VArsenalClass) call ["sync", [_data, _jip]]; + GVAR(VAClass) call ["sync", [_data, _jip]]; }] call CFUNC(addEventHandler); [{ diff --git a/arma/client/addons/locker/XEH_preStart.sqf b/arma/client/addons/locker/XEH_preStart.sqf index 0228885..a51262a 100644 --- a/arma/client/addons/locker/XEH_preStart.sqf +++ b/arma/client/addons/locker/XEH_preStart.sqf @@ -1,3 +1,2 @@ #include "script_component.hpp" - #include "XEH_PREP.hpp" diff --git a/arma/client/addons/locker/functions/fnc_handleUIEvents.sqf b/arma/client/addons/locker/functions/fnc_handleUIEvents.sqf deleted file mode 100644 index 2996bad..0000000 --- a/arma/client/addons/locker/functions/fnc_handleUIEvents.sqf +++ /dev/null @@ -1,17 +0,0 @@ -#include "..\script_component.hpp" - -/* - * Author: IDSolutions - * Handles the UI events. - * - * Arguments: - * None - * - * Return Value: - * None - * - * Example: - * [] call forge_client_locker_fnc_handleUIEvents; - * - * Public: No - */ diff --git a/arma/client/addons/locker/functions/fnc_initLockerClass.sqf b/arma/client/addons/locker/functions/fnc_initLockerClass.sqf index 051fe1c..68c5a94 100644 --- a/arma/client/addons/locker/functions/fnc_initLockerClass.sqf +++ b/arma/client/addons/locker/functions/fnc_initLockerClass.sqf @@ -1,73 +1,299 @@ #include "..\script_component.hpp" /* + * File: fnc_initLockerClass.sqf * Author: IDSolutions - * Initializes the locker class. + * Date: 2025-12-17 + * Last Update: 2026-02-13 + * Public: No + * + * Description: + * Initializes the Locker class for managing player locker items. + * Provides methods for syncing, saving, and applying locker items to the player's locker. * * Arguments: * None * * Return Value: - * None + * Locker class object [HASHMAP OBJECT] * * Example: - * [] call forge_client_locker_fnc_initLockerClass; - * - * Public: No + * call forge_client_locker_fnc_initLockerClass */ #pragma hemtt ignore_variables ["_self"] -GVAR(LockerClass) = createHashMapObject [[ - ["#type", "ILockerClass"], - ["#create", { +GVAR(LockerBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "LockerBaseClass"], + ["#create", compileFinal { _self set ["uid", (getPlayerUID player)]; - _self set ["locker", createHashMap]; _self set ["isLoaded", false]; _self set ["lastSave", time]; + _self set ["locker", createHashMap]; }], - ["init", { + ["init", compileFinal { private _uid = _self get "uid"; - private _locker = _self get "locker"; - [SRPC(locker,requestInitLocker), [_uid, _locker]] call CFUNC(serverEvent); + [SRPC(locker,requestInitLocker), [_uid]] call CFUNC(serverEvent); systemChat format ["Locker loaded for %1", (name player)]; diag_log "[FORGE:Client:Locker] Locker Class Initialized!"; }], - ["save", { - params [["_sync", false, [false]]]; - - private _uid = _self get "uid"; - [SRPC(locker,requestSaveLocker), [_uid, _sync]] call CFUNC(serverEvent); - - _self set ["lastSave", time]; - }], - ["sync", { - params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; - - private _locker = _self get "locker"; - private _isLoaded = _self get "isLoaded"; - - if (_data isEqualTo createHashMap) exitWith { - diag_log "[FORGE:Client:Locker] Empty data received for sync, skipping."; - }; - - { - _locker set [_x, _y]; - } forEach _data; - - _self set ["locker", _locker]; - - if !(_isLoaded) then { _self set ["isLoaded", true]; }; - diag_log "[FORGE:Client:Locker] Sync completed"; - }], - ["get", { + ["get", compileFinal { params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; private _locker = _self get "locker"; _locker getOrDefault [_key, _default]; - }] -]]; + }], + ["getCargo", compileFinal { + params [["_container", objNull, [objNull]], ["_locker", createHashMap, [createHashMap]]]; -SETVAR(player,FORGE_LockerClass,GVAR(LockerClass)); + private _cargoData = [ + ["item", getItemCargo _container], + ["weapon", getWeaponCargo _container], + ["magazine", getMagazineCargo _container], + ["backpack", getBackpackCargo _container] + ]; + + { + _x params ["_category", "_data"]; + _data params ["_classes", "_counts"]; + + { + private _class = _x; + private _count = _counts select _forEachIndex; + + _locker set [_class, createHashMapFromArray [ + ["amount", _count], + ["classname", _class], + ["category", _category] + ]]; + } forEach _classes; + } forEach _cargoData; + + _locker + }], + ["getContainerItems", compileFinal { + params [["_container", objNull, [objNull]], ["_locker", createHashMap, [createHashMap]]]; + + private _allContainers = everyContainer _container; + { + _x params ["_containerClass", "_containerObj"]; + + private _cfgVehicles = configFile >> "CfgVehicles" >> _containerClass; + private _cfgWeapons = configFile >> "CfgWeapons" >> _containerClass; + private _itemInfoType = getNumber (_cfgWeapons >> "ItemInfo" >> "type"); + private _isBackpack = isClass _cfgVehicles; + private _isUniform = isClass _cfgWeapons && {_itemInfoType == TYPE_UNIFORM}; + private _isVest = isClass _cfgWeapons && {_itemInfoType == TYPE_VEST}; + + if (!_isBackpack && !_isVest && !_isUniform) then { continue; }; + + private _containerItems = getItemCargo _containerObj; + _containerItems params ["_classes", "_counts"]; + { + private _class = _x; + private _count = _counts select _forEachIndex; + private _existing = _locker getOrDefault [_class, createHashMap]; + private _existingCount = _existing getOrDefault ["amount", 0]; + + _locker set [_class, createHashMapFromArray [ + ["amount", _existingCount + _count], + ["classname", _class], + ["category", "item"] + ]]; + } forEach _classes; + + private _containerMags = getMagazineCargo _containerObj; + _containerMags params ["_classes", "_counts"]; + { + private _class = _x; + private _count = _counts select _forEachIndex; + private _existing = _locker getOrDefault [_class, createHashMap]; + private _existingCount = _existing getOrDefault ["amount", 0]; + + _locker set [_class, createHashMapFromArray [ + ["amount", _existingCount + _count], + ["classname", _class], + ["category", "magazine"] + ]]; + } forEach _classes; + + private _containerWeapons = getWeaponCargo _containerObj; + _containerWeapons params ["_classes", "_counts"]; + { + private _class = _x; + private _count = _counts select _forEachIndex; + private _existing = _locker getOrDefault [_class, createHashMap]; + private _existingCount = _existing getOrDefault ["amount", 0]; + + _locker set [_class, createHashMapFromArray [ + ["amount", _existingCount + _count], + ["classname", _class], + ["category", "weapon"] + ]]; + } forEach _classes; + } forEach _allContainers; + + _locker + }], + ["getAttachments", compileFinal { + params [["_container", objNull, [objNull]], ["_locker", createHashMap, [createHashMap]]]; + + private _weaponItems = weaponsItemsCargo _container; + { + // private _weapon = _x param [0, ""]; + private _muzzle = _x param [1, ""]; + private _pointer = _x param [2, ""]; + private _optic = _x param [3, ""]; + private _primaryMag = _x param [4, ["", 0]]; + private _underbarrel = _x param [5, ""]; + private _bipod = _x param [6, ""]; + private _secondaryMag = _x param [7, ["", 0]]; + private _attachments = [_muzzle, _pointer, _optic, _underbarrel, _bipod] select {(_x isEqualType "") && {_x != ""}}; + { + private _existing = _locker getOrDefault [_x, createHashMap]; + private _existingCount = _existing getOrDefault ["amount", 0]; + + _locker set [_x, createHashMapFromArray [ + ["amount", _existingCount + 1], + ["classname", _x], + ["category", "item"] + ]]; + } forEach _attachments; + + if (_primaryMag isNotEqualTo ["", 0]) then { + _primaryMag params ["_magClass", "_ammoCount"]; // TODO: Add ammo count to locker + if (_magClass != "") then { + private _existing = _locker getOrDefault [_magClass, createHashMap]; + private _existingCount = _existing getOrDefault ["amount", 0]; + + _locker set [_magClass, createHashMapFromArray [ + ["amount", _existingCount + 1], + ["classname", _magClass], + ["category", "magazine"] + ]]; + }; + }; + + if (_secondaryMag isNotEqualTo ["", 0]) then { + _secondaryMag params ["_magClass", "_ammoCount"]; // TODO: Add ammo count to locker + if (_magClass != "") then { + private _existing = _locker getOrDefault [_magClass, createHashMap]; + private _existingCount = _existing getOrDefault ["amount", 0]; + + _locker set [_magClass, createHashMapFromArray [ + ["amount", _existingCount + 1], + ["classname", _magClass], + ["category", "magazine"] + ]]; + }; + }; + } forEach _weaponItems; + + _locker + }], + ["save", compileFinal { + private _uid = _self get "uid"; + [SRPC(locker,requestSaveLocker), [_uid]] call CFUNC(serverEvent); + + _self set ["lastSave", time]; + }], + ["setEventHandlers", compileFinal { + params [["_locker", objNull, [objNull]]]; + + _locker addEventHandler ["ContainerOpened", { + params ["_container", "_unit"]; + + private _index = GVAR(LockerClass) get "locker"; + + clearBackpackCargo _container; + clearItemCargo _container; + clearMagazineCargo _container; + clearWeaponCargo _container; + + { + private _amount = _y get "amount"; + private _category = _y get "category"; + private _className = _y get "classname"; + + switch (_category) do { + case "backpack": { _container addBackpackCargo [_className, _amount]; }; + case "item": { _container addItemCargo [_className, _amount]; }; + case "magazine": { _container addMagazineCargo [_className, _amount]; }; + case "weapon": { _container addWeaponCargo [_className, _amount]; }; + default { _container addItemCargo [_className, _amount]; }; + }; + } forEach _index; + + if (count _index > 25) then { + private _params = ["warning", "Over Capacity", "Locker has more then 25 items, please remove some items", 3000]; + GVAR(NotificationClass) call ["create", _params]; + }; + }]; + + _locker addEventHandler ["ContainerClosed", { + params ["_container", "_unit"]; + + private _newLocker = createHashMap; + _newLocker = GVAR(LockerClass) call ["getCargo", [_container, _newLocker]]; + _newLocker = GVAR(LockerClass) call ["getContainerItems", [_container, _newLocker]]; + _newLocker = GVAR(LockerClass) call ["getAttachments", [_container, _newLocker]]; + + private _uid = getPlayerUID _unit; + [SRPC(locker,requestOverrideLocker), [_uid, _newLocker]] call CFUNC(serverEvent); + GVAR(LockerClass) set ["locker", _newLocker]; + + if (count _newLocker > 25) then { + private _params = ["warning", "Over Capacity", "Locker has more then 25 items, please remove some items", 3000]; + GVAR(NotificationClass) call ["create", _params]; + }; + }]; + }], + ["setup", compileFinal { + private _lockers = (allVariables missionNamespace) select { + private _var = missionNamespace getVariable _x; + ("locker" in _x) && { _var isEqualType objNull } && { !isNull _var } && { _x isNotEqualTo "forge_locker_box" } + }; + + if (_lockers isEqualTo []) exitWith { diag_log "[FORGE:Client:Locker] No lockers found in missionNamespace."; }; + + { + private _globalLocker = missionNamespace getVariable _x; + private _pos = getPosASL _globalLocker; + private _vDir = vectorDir _globalLocker; + private _vUp = vectorUp _globalLocker; + + private _localLocker = createVehicleLocal ["Box_NATO_Equip_F", [0, 0, 0]]; + _localLocker setPosASL _pos; + _localLocker setVectorDirAndUp [_vDir, _vUp]; + _localLocker allowDamage false; + _localLocker setVariable ["isLocker", true]; + + clearBackpackCargo _localLocker; + clearItemCargo _localLocker; + clearMagazineCargo _localLocker; + clearWeaponCargo _localLocker; + + private _localVarName = format ["FORGE_Locker_Local_%1", _forEachIndex]; + _localLocker setVehicleVarName _localVarName; + missionNamespace setVariable [_localVarName, _localLocker]; + + _self call ["setEventHandlers", [_localLocker]]; + } forEach _lockers; + }], + ["sync", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _isLoaded = _self get "isLoaded"; + private _locker = _self get "locker"; + + { _locker set [_x, _y]; } forEach _data; + _self set ["locker", _locker]; + + if !(_isLoaded) then { _self set ["isLoaded", true]; _self call ["setup", []]; }; + diag_log "[FORGE:Client:Locker] Sync completed"; + }] +]; + +GVAR(LockerClass) = createHashMapObject [GVAR(LockerBaseClass)]; GVAR(LockerClass) diff --git a/arma/client/addons/locker/functions/fnc_initVAClass.sqf b/arma/client/addons/locker/functions/fnc_initVAClass.sqf index 12576ee..5711d63 100644 --- a/arma/client/addons/locker/functions/fnc_initVAClass.sqf +++ b/arma/client/addons/locker/functions/fnc_initVAClass.sqf @@ -1,80 +1,64 @@ #include "..\script_component.hpp" /* - * File: fnc_initVAClass.sqf + * File: fnc_init.sqf * Author: IDSolutions * Date: 2025-12-16 - * Last Update: 2025-12-17 + * Last Update: 2026-02-13 * Public: No * * Description: - * Initializes the Virtual Arsenal class for managing player arsenal unlocks. - * Provides methods for syncing, saving, and applying virtual items to BIS Arsenal. + * Initializes the Virtual Arsenal class for managing player arsenal unlocks. + * Provides methods for syncing, saving, and applying virtual items to BIS Arsenal. * - * Parameter(s): - * None + * Arguments: + * None * - * Returns: - * vArsenal class object [HASHMAP OBJECT] + * Return Value: + * vArsenal class object [HASHMAP OBJECT] * - * Example(s): - * [] call forge_client_locker_fnc_initVAClass; + * Example: + * call forge_client_locker_fnc_init; */ #pragma hemtt ignore_variables ["_self"] -GVAR(VArsenalClass) = createHashMapObject [[ - ["#type", "IVArsenalClass"], - ["#create", { +GVAR(VABaseClass) = compileFinal createHashMapFromArray [ + ["#type", "VABaseClass"], + ["#create", compileFinal { _self set ["uid", (getPlayerUID player)]; _self set ["vArsenal", createHashMap]; _self set ["isLoaded", false]; _self set ["lastSave", time]; - - private _vArsenal = createHashMap; - _vArsenal set ["items", []]; - _vArsenal set ["weapons", []]; - _vArsenal set ["magazines", []]; - _vArsenal set ["backpacks", []]; - - _self set ["vArsenal", _vArsenal]; }], - ["init", { + ["init", compileFinal { private _uid = _self get "uid"; - private _vArsenal = _self get "vArsenal"; - - [SRPC(locker,requestInitVA), [_uid, _vArsenal]] call CFUNC(serverEvent); FORGE_Locker_Box = "ReammoBox_F" createVehicleLocal [0, 0, -999]; + [SRPC(locker,requestInitVA), [_uid]] call CFUNC(serverEvent); systemChat format ["VArsenal loaded for %1", (name player)]; diag_log "[FORGE:Client:VArsenal] VArsenal Class Initialized!"; }], - ["save", { - params [["_sync", false, [false]]]; - + ["save", compileFinal { private _uid = _self get "uid"; - [SRPC(locker,requestSaveVA), [_uid, _sync]] call CFUNC(serverEvent); + [SRPC(locker,requestSaveVA), [_uid]] call CFUNC(serverEvent); _self set ["lastSave", time]; }], - ["sync", { - params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; + ["sync", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; private _vArsenal = _self get "vArsenal"; private _isLoaded = _self get "isLoaded"; - if (_data isEqualTo createHashMap) exitWith { diag_log "[FORGE:Client:VArsenal] Empty data received for sync, skipping."; }; - { _vArsenal set [_x, _y]; - if (_jip) then { - switch (_x) do { - case "items": { _self call ["applyItems", []]; }; - case "weapons": { _self call ["applyWeapons", []]; }; - case "magazines": { _self call ["applyMagazines", []]; }; - case "backpacks": { _self call ["applyBackpacks", []]; }; - default {}; - }; + switch (_x) do { + case "items": { _self call ["applyItems", []]; }; + case "weapons": { _self call ["applyWeapons", []]; }; + case "magazines": { _self call ["applyMagazines", []]; }; + case "backpacks": { _self call ["applyBackpacks", []]; }; + default {}; }; } forEach _data; @@ -83,29 +67,29 @@ GVAR(VArsenalClass) = createHashMapObject [[ if !(_isLoaded) then { _self set ["isLoaded", true]; }; diag_log "[FORGE:Client:VArsenal] Sync completed"; }], - ["get", { + ["get", compileFinal { params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; private _vArsenal = _self get "vArsenal"; _vArsenal getOrDefault [_key, _default]; }], - ["applyItems", { + ["applyItems", compileFinal { private _items = _self call ["get", ["items", []]]; - [FORGE_Armory_Box, _items, false, true, 1, 0] call BFUNC(addVirtualItemCargo); + [FORGE_Locker_Box, _items] call AFUNC(arsenal,addVirtualItems); }], - ["applyWeapons", { + ["applyWeapons", compileFinal { private _weapons = _self call ["get", ["weapons", []]]; - [FORGE_Armory_Box, _weapons, false, true, 1, 1] call BFUNC(addVirtualItemCargo); + [FORGE_Locker_Box, _weapons] call AFUNC(arsenal,addVirtualItems); }], - ["applyMagazines", { + ["applyMagazines", compileFinal { private _magazines = _self call ["get", ["magazines", []]]; - [FORGE_Armory_Box, _magazines, false, true, 1, 2] call BFUNC(addVirtualItemCargo); + [FORGE_Locker_Box, _magazines] call AFUNC(arsenal,addVirtualItems); }], - ["applyBackpacks", { + ["applyBackpacks", compileFinal { private _backpacks = _self call ["get", ["backpacks", []]]; - [FORGE_Armory_Box, _backpacks, false, true, 1, 3] call BFUNC(addVirtualItemCargo); + [FORGE_Locker_Box, _backpacks] call AFUNC(arsenal,addVirtualItems); }] -]]; +]; -SETVAR(player,FORGE_VArsenalClass,GVAR(VArsenalClass)); -GVAR(VArsenalClass) +GVAR(VAClass) = createHashMapObject [GVAR(VABaseClass)]; +GVAR(VAClass) diff --git a/arma/client/addons/locker/functions/fnc_openUI.sqf b/arma/client/addons/locker/functions/fnc_openUI.sqf deleted file mode 100644 index c9fcbd1..0000000 --- a/arma/client/addons/locker/functions/fnc_openUI.sqf +++ /dev/null @@ -1,17 +0,0 @@ -#include "..\script_component.hpp" - -/* - * Author: IDSolutions - * Opens the locker UI. - * - * Arguments: - * None - * - * Return Value: - * None - * - * Example: - * [] call forge_client_locker_fnc_openUI; - * - * Public: No - */ diff --git a/arma/client/addons/main/README.md b/arma/client/addons/main/README.md index d2499a7..0f8cf55 100644 --- a/arma/client/addons/main/README.md +++ b/arma/client/addons/main/README.md @@ -1,4 +1,3 @@ -forge_client_main -=================== +# forge_client_main Main Addon for forge-client diff --git a/arma/client/addons/notifications/CfgSounds.hpp b/arma/client/addons/notifications/CfgSounds.hpp new file mode 100644 index 0000000..ae86ed6 --- /dev/null +++ b/arma/client/addons/notifications/CfgSounds.hpp @@ -0,0 +1,9 @@ +class CfgSounds { + sounds[] += {QGVAR(notify)}; + + class GVAR(notify) { + name = QGVAR(notify); + sound[] = {QPATHTOF2(sounds\notify.ogg), 1, 1}; + titles[] = {}; + }; +}; diff --git a/arma/client/addons/notifications/README.md b/arma/client/addons/notifications/README.md index 9d0e00a..ea21f89 100644 --- a/arma/client/addons/notifications/README.md +++ b/arma/client/addons/notifications/README.md @@ -1,4 +1,3 @@ -forge_client_notifications -=================== +# forge_client_notifications Description for this addon diff --git a/arma/client/addons/notifications/XEH_postInitClient.sqf b/arma/client/addons/notifications/XEH_postInitClient.sqf index 6dc4bcd..6eada6a 100644 --- a/arma/client/addons/notifications/XEH_postInitClient.sqf +++ b/arma/client/addons/notifications/XEH_postInitClient.sqf @@ -4,12 +4,13 @@ EGVAR(actor,ActorClass) get "isLoaded"; }, { ("NotificationHudLayer" call BFUNC(rscLayer)) cutRsc ["RscNotifications", "PLAIN"]; - [] call FUNC(openUI); - if (isNil QGVAR(NotificationClass)) then { [] call FUNC(initNotificationClass); }; + call FUNC(openUI); + if (isNil QGVAR(NotificationClass)) then { call FUNC(initNotificationClass); }; }] call CFUNC(waitUntilAndExecute); [QGVAR(recieveNotification), { params [["_type", "", [""]], ["_title", "", [""]], ["_content", "", [""]], ["_duration", 4000, [4000]]]; + playSound QGVAR(notify); GVAR(NotificationClass) call ["create", [_type, _title, _content, _duration]]; }] call CFUNC(addEventHandler); diff --git a/arma/client/addons/notifications/config.cpp b/arma/client/addons/notifications/config.cpp index fdb31cc..e458924 100644 --- a/arma/client/addons/notifications/config.cpp +++ b/arma/client/addons/notifications/config.cpp @@ -16,6 +16,7 @@ class CfgPatches { }; }; +#include "CfgSounds.hpp" #include "CfgEventHandlers.hpp" #include "ui\RscCommon.hpp" #include "ui\RscNotifications.hpp" diff --git a/arma/client/addons/notifications/functions/fnc_handleUIEvents.sqf b/arma/client/addons/notifications/functions/fnc_handleUIEvents.sqf index ecfc19e..7c84d95 100644 --- a/arma/client/addons/notifications/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/notifications/functions/fnc_handleUIEvents.sqf @@ -1,19 +1,25 @@ #include "..\script_component.hpp" /* + * File: fnc_handleUIEvents.sqf * Author: IDSolutions - * Handles UI events. + * Date: 2026-01-28 + * Last Update: 2026-01-30 + * Public: No + * + * Description: + * Handles the UI events. * * Arguments: - * None + * 0: [CONTROL] - The control that triggered the event + * 1: [BOOL] - Whether the event is from a confirm dialog + * 2: [STRING] - The message containing the event data * * Return Value: - * None + * UI events handled [BOOL] * * Example: - * [] call forge_client_notifications_fnc_handleUIEvents; - * - * Public: No + * call forge_client_notifications_fnc_handleUIEvents; */ params ["_control", "_isConfirmDialog", "_message"]; diff --git a/arma/client/addons/notifications/functions/fnc_initNotificationClass.sqf b/arma/client/addons/notifications/functions/fnc_initNotificationClass.sqf index 7558d01..06ed9cc 100644 --- a/arma/client/addons/notifications/functions/fnc_initNotificationClass.sqf +++ b/arma/client/addons/notifications/functions/fnc_initNotificationClass.sqf @@ -1,19 +1,24 @@ #include "..\script_component.hpp" /* + * File: fnc_initNotificationClass.sqf * Author: IDSolutions - * Initialize notification class + * Date: 2026-01-28 + * Last Update: 2026-01-30 + * Public: No + * + * Description: + * Initializes the notification class for managing player notifications. + * Provides methods for creating and displaying notifications. * * Arguments: - * N/A + * None * * Return Value: - * N/A + * Notification class object [HASHMAP OBJECT] * - * Examples: - * [] call forge_client_notifications_fnc_initNotificationClass - * - * Public: Yes + * Example: + * call forge_client_notifications_fnc_initNotificationClass */ #pragma hemtt ignore_variables ["_self"] @@ -50,5 +55,4 @@ GVAR(NotificationClass) = createHashMapObject [[ }] ]]; -SETVAR(player,FORGE_NotificationClass,GVAR(NotificationClass)); GVAR(NotificationClass) diff --git a/arma/client/addons/notifications/functions/fnc_openUI.sqf b/arma/client/addons/notifications/functions/fnc_openUI.sqf index 49d7eff..f3af405 100644 --- a/arma/client/addons/notifications/functions/fnc_openUI.sqf +++ b/arma/client/addons/notifications/functions/fnc_openUI.sqf @@ -1,19 +1,23 @@ #include "..\script_component.hpp" /* + * File: fnc_openUI.sqf * Author: IDSolutions - * Open notification interface. + * Date: 2026-01-28 + * Last Update: 2026-01-30 + * Public: No + * + * Description: + * Opens the notification interface. * * Arguments: * None * * Return Value: - * None + * UI opened [BOOL] * * Example: - * [] call forge_client_notifications_fnc_openUI; - * - * Public: No + * call forge_client_notifications_fnc_openUI; */ private _display = uiNamespace getVariable ["RscNotifications", nil]; diff --git a/arma/client/addons/notifications/sounds/notify.ogg b/arma/client/addons/notifications/sounds/notify.ogg new file mode 100644 index 0000000..06e8125 Binary files /dev/null and b/arma/client/addons/notifications/sounds/notify.ogg differ diff --git a/arma/client/addons/notifications/ui/_site/index.html b/arma/client/addons/notifications/ui/_site/index.html index 39588c9..d1d769a 100644 --- a/arma/client/addons/notifications/ui/_site/index.html +++ b/arma/client/addons/notifications/ui/_site/index.html @@ -1,38 +1,44 @@ - + + + + + Forge - Notification System + + + - - - - -
- + const script = document.createElement("script"); + script.text = js; + document.head.appendChild(script); + }); + + + +
+
+
+ + diff --git a/arma/client/addons/notifications/ui/_site/script.js b/arma/client/addons/notifications/ui/_site/script.js index baf5b88..7bb10f2 100644 --- a/arma/client/addons/notifications/ui/_site/script.js +++ b/arma/client/addons/notifications/ui/_site/script.js @@ -3,10 +3,10 @@ //============================================================================= const NotificationActionTypes = { - ADD_NOTIFICATION: 'ADD_NOTIFICATION', - REMOVE_NOTIFICATION: 'REMOVE_NOTIFICATION', - CLEAR_NOTIFICATIONS: 'CLEAR_NOTIFICATIONS', - UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION' + ADD_NOTIFICATION: "ADD_NOTIFICATION", + REMOVE_NOTIFICATION: "REMOVE_NOTIFICATION", + CLEAR_NOTIFICATIONS: "CLEAR_NOTIFICATIONS", + UPDATE_NOTIFICATION: "UPDATE_NOTIFICATION", }; const notificationActions = { @@ -15,25 +15,25 @@ const notificationActions = { payload: { id: Date.now() + Math.random(), timestamp: Date.now(), - type: 'info', - title: 'Notification', - message: 'Default message', + type: "info", + title: "Notification", + message: "Default message", duration: 0, - status: 'showing', - ...notification - } + status: "showing", + ...notification, + }, }), removeNotification: (id) => ({ type: NotificationActionTypes.REMOVE_NOTIFICATION, - payload: { id } + payload: { id }, }), clearNotifications: () => ({ - type: NotificationActionTypes.CLEAR_NOTIFICATIONS + type: NotificationActionTypes.CLEAR_NOTIFICATIONS, }), updateNotification: (id, updates) => ({ type: NotificationActionTypes.UPDATE_NOTIFICATION, - payload: { id, updates } - }) + payload: { id, updates }, + }), }; //============================================================================= @@ -42,7 +42,7 @@ const notificationActions = { const notificationInitialState = { notifications: [], - maxNotifications: 5 + maxNotifications: 3, }; function notificationReducer(state = notificationInitialState, action = {}) { @@ -53,24 +53,36 @@ function notificationReducer(state = notificationInitialState, action = {}) { if (newNotifications.length >= state.maxNotifications) { newNotifications = newNotifications.slice(1); } - return { ...state, notifications: [...newNotifications, action.payload] }; + return { + ...state, + notifications: [...newNotifications, action.payload], + }; } case NotificationActionTypes.REMOVE_NOTIFICATION: { if (!action.payload || !action.payload.id) return state; return { ...state, - notifications: state.notifications.filter(n => n.id !== action.payload.id) + notifications: state.notifications.filter( + (n) => n.id !== action.payload.id, + ), }; } case NotificationActionTypes.CLEAR_NOTIFICATIONS: return { ...state, notifications: [] }; case NotificationActionTypes.UPDATE_NOTIFICATION: { - if (!action.payload || !action.payload.id || !action.payload.updates) return state; + if ( + !action.payload || + !action.payload.id || + !action.payload.updates + ) + return state; return { ...state, - notifications: state.notifications.map(n => - n.id === action.payload.id ? { ...n, ...action.payload.updates } : n - ) + notifications: state.notifications.map((n) => + n.id === action.payload.id + ? { ...n, ...action.payload.updates } + : n, + ), }; } default: @@ -95,19 +107,22 @@ class Store { dispatch(action) { this.state = this.reducer(this.state, action); - this.listeners.forEach(listener => listener(this.state)); + this.listeners.forEach((listener) => listener(this.state)); return action; } subscribe(listener) { this.listeners.push(listener); return () => { - this.listeners = this.listeners.filter(l => l !== listener); + this.listeners = this.listeners.filter((l) => l !== listener); }; } } -const notificationStore = new Store(notificationReducer, notificationInitialState); +const notificationStore = new Store( + notificationReducer, + notificationInitialState, +); //============================================================================= // #region SELECTORS @@ -115,7 +130,7 @@ const notificationStore = new Store(notificationReducer, notificationInitialStat const notificationSelectors = { getNotifications: (state) => state.notifications, - getMaxNotifications: (state) => state.maxNotifications + getMaxNotifications: (state) => state.maxNotifications, }; //============================================================================= @@ -126,13 +141,14 @@ class NotificationUI { constructor(store) { this.store = store; this.unsubscribe = null; - this.container = document.getElementById('notification-container'); + this.container = document.getElementById("notification-container"); this.renderedNotifications = new Map(); + this.dismissTimers = new Map(); } init() { if (!this.container) { - console.error('Notification container not found'); + console.error("Notification container not found"); return; } this.unsubscribe = this.store.subscribe((state) => this.render(state)); @@ -141,7 +157,13 @@ class NotificationUI { destroy() { if (this.unsubscribe) this.unsubscribe(); - this.renderedNotifications.forEach(el => { + this.dismissTimers.forEach((timers) => { + clearTimeout(timers.hideTimer); + clearTimeout(timers.removeTimer); + clearTimeout(timers.progressTimer); + }); + this.dismissTimers.clear(); + this.renderedNotifications.forEach((el) => { if (el.parentNode) el.parentNode.removeChild(el); }); this.renderedNotifications.clear(); @@ -151,16 +173,17 @@ class NotificationUI { const notifications = notificationSelectors.getNotifications(state); // Remove notifications no longer present - const currentIds = new Set(notifications.map(n => n.id)); + const currentIds = new Set(notifications.map((n) => n.id)); for (const [id, el] of this.renderedNotifications.entries()) { if (!currentIds.has(id)) { + this.clearDismissTimers(id); if (el.parentNode) el.parentNode.removeChild(el); this.renderedNotifications.delete(id); } } // Add or update notifications - notifications.forEach(notification => { + notifications.forEach((notification) => { if (!notification || !notification.id) return; if (!this.renderedNotifications.has(notification.id)) { this.createNotificationElement(notification); @@ -170,45 +193,133 @@ class NotificationUI { }); } + clearDismissTimers(id) { + const timers = this.dismissTimers.get(id); + if (!timers) return; + + clearTimeout(timers.hideTimer); + clearTimeout(timers.removeTimer); + clearTimeout(timers.progressTimer); + this.dismissTimers.delete(id); + } + + escapeHTML(value) { + return String(value == null ? "" : value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + normalizeType(type) { + const supportedTypes = new Set([ + "success", + "danger", + "warning", + "info", + ]); + return supportedTypes.has(type) ? type : "info"; + } + + formatTypeLabel(type) { + const labels = { + success: "Success", + danger: "Critical", + warning: "Warning", + info: "Info", + }; + return labels[this.normalizeType(type)] || labels.info; + } + + getDurationLabel(duration) { + if (!(duration > 0)) return "Pinned"; + const seconds = Math.max(1, Math.round(duration / 100) / 10); + return `${seconds.toFixed(1)}s`; + } + + getTimestampLabel(timestamp) { + const date = new Date(timestamp || Date.now()); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${hours}:${minutes}`; + } + createNotificationElement(notification) { - const el = document.createElement('div'); - el.className = `notification ${notification.type || 'info'}`; + const type = this.normalizeType(notification.type); + const title = this.escapeHTML(notification.title || "Notification"); + const message = this.escapeHTML(notification.message || "No message"); + const isPersistent = !(notification.duration > 0); + const el = document.createElement("div"); + el.className = `notification ${type}${isPersistent ? " is-persistent" : ""}`; el.dataset.id = notification.id; el.innerHTML = ` -
-
${notification.title || 'Notification'}
+
+
+
${title}
+
Forge alert
+
+
+
+ ${this.formatTypeLabel(type)} + ${this.getTimestampLabel(notification.timestamp)} +
+
${message}
+ +
-
${notification.message || 'No message'}
- ${notification.duration > 0 ? '
' : ''} + ${notification.duration > 0 ? '
' : ""} `; this.container.appendChild(el); this.renderedNotifications.set(notification.id, el); - setTimeout(() => el.classList.add('show'), 10); + requestAnimationFrame(() => { + requestAnimationFrame(() => el.classList.add("show")); + }); // Set progress bar animation duration if (notification.duration > 0) { - const progressBar = el.querySelector('.notification-progress-bar'); + const progressBar = el.querySelector(".notification-progress-bar"); if (progressBar) { - progressBar.style.transition = `width ${notification.duration}ms linear`; - progressBar.style.width = '100%'; - setTimeout(() => { - progressBar.style.width = '0%'; - }, 20); + progressBar.style.transitionDuration = `${notification.duration}ms`; + const progressTimer = setTimeout(() => { + progressBar.style.transform = "scaleX(0)"; + }, 30); + this.dismissTimers.set(notification.id, { progressTimer }); } - setTimeout(() => { - notificationStore.dispatch(notificationActions.updateNotification(notification.id, { status: 'hiding' })); - setTimeout(() => { - notificationStore.dispatch(notificationActions.removeNotification(notification.id)); - }, 300); + const hideTimer = setTimeout(() => { + notificationStore.dispatch( + notificationActions.updateNotification(notification.id, { + status: "hiding", + }), + ); }, notification.duration); + const removeTimer = setTimeout(() => { + this.clearDismissTimers(notification.id); + notificationStore.dispatch( + notificationActions.removeNotification(notification.id), + ); + }, notification.duration + 260); + + const existingTimers = + this.dismissTimers.get(notification.id) || {}; + this.dismissTimers.set(notification.id, { + ...existingTimers, + hideTimer, + removeTimer, + }); } } updateNotificationElement(notification) { const el = this.renderedNotifications.get(notification.id); if (!el) return; - if (notification.status === 'hiding') el.classList.add('hide'); + if (notification.status === "hiding") { + el.classList.add("hide"); + } } } @@ -220,7 +331,11 @@ let notificationUI = null; let notificationUIInitialized = false; function notifyArmaNotificationReady() { - if (window.parent && window.parent !== window && typeof window.parent.postMessage === "function") { + if ( + window.parent && + window.parent !== window && + typeof window.parent.postMessage === "function" + ) { window.parent.postMessage({ event: "notifications::ready" }, "*"); } if (typeof A3API !== "undefined" && typeof A3API.SendAlert === "function") { @@ -230,34 +345,44 @@ function notifyArmaNotificationReady() { function initializeNotifications() { if (notificationUIInitialized) { - console.log('Notification system already initialized, skipping...'); + console.log("Notification system already initialized, skipping..."); return; } notificationUI = new NotificationUI(notificationStore); notificationUI.init(); notificationUIInitialized = true; - console.log('Notification system is ready!'); + console.log("Notification system is ready!"); notifyArmaNotificationReady(); } // Expose global notification API const showNotification = (type, title, message, duration) => { - return notificationStore.dispatch(notificationActions.addNotification({ type, title, message, duration })); + return notificationStore.dispatch( + notificationActions.addNotification({ type, title, message, duration }), + ); }; const clearAllNotifications = () => { return notificationStore.dispatch(notificationActions.clearNotifications()); }; +window.showNotification = showNotification; +window.clearAllNotifications = clearAllNotifications; +window.ForgeNotifications = { + show: showNotification, + clear: clearAllNotifications, +}; // Listen for global notification events (for Arma/SQF or other scripts) -window.addEventListener('forge:notify', function (e) { +window.addEventListener("forge:notify", function (e) { if (!e || !e.detail) return; const { type, title, message, duration } = e.detail; showNotification(type, title, message, duration); }); // Auto-initialize if DOM is already loaded when script executes -if (document.readyState !== 'loading') { +if (document.readyState !== "loading") { initializeNotifications(); } else { - document.addEventListener('DOMContentLoaded', initializeNotifications, { once: true }); -} \ No newline at end of file + document.addEventListener("DOMContentLoaded", initializeNotifications, { + once: true, + }); +} diff --git a/arma/client/addons/notifications/ui/_site/styles.css b/arma/client/addons/notifications/ui/_site/styles.css index 9fdea12..685c9b4 100644 --- a/arma/client/addons/notifications/ui/_site/styles.css +++ b/arma/client/addons/notifications/ui/_site/styles.css @@ -1,174 +1,245 @@ +:root { + --hud-top: 30px; + --hud-right: 20px; + --header-bg: linear-gradient( + 180deg, + rgba(13, 37, 69, 0.98) 0%, + rgba(8, 24, 48, 0.98) 100% + ); + --body-bg: rgba(242, 238, 228, 0.97); + --panel-edge: rgba(121, 166, 212, 0.22); + --panel-shadow: 0 14px 24px rgba(0, 0, 0, 0.34); + --header-text: rgba(235, 243, 255, 0.98); + --header-muted: rgba(166, 189, 221, 0.8); + --body-text: rgba(31, 45, 64, 0.96); + --body-muted: rgba(86, 102, 122, 0.82); + --body-faint: rgba(111, 126, 144, 0.76); + --success: #6de2b3; + --success-soft: rgba(109, 226, 179, 0.12); + --danger: #ff7b7b; + --danger-soft: rgba(255, 123, 123, 0.12); + --warning: #ffd36b; + --warning-soft: rgba(255, 211, 107, 0.12); + --info: #78b9ff; + --info-soft: rgba(120, 185, 255, 0.12); +} + * { margin: 0; padding: 0; box-sizing: border-box; } +html, body { - font-family: Arial, sans-serif; - background: transparent; min-height: 100vh; - color: rgba(200, 220, 240, 0.95); +} + +body { + background: transparent; + color: var(--body-text); + font-family: "Bahnschrift", "Segoe UI", Tahoma, sans-serif; + letter-spacing: 0.01em; + overflow: hidden; + pointer-events: none; + user-select: none; +} + +.notifications-hud { + position: fixed; + top: var(--hud-top); + right: var(--hud-right); + width: 384px; + max-width: calc(100vw - 24px); + z-index: 1000; +} + +.notification-container { + display: grid; + gap: 8px; +} + +.notification { + position: relative; + overflow: hidden; + background: transparent; + border: 1px solid var(--panel-edge); + border-radius: 12px; + box-shadow: var(--panel-shadow); + opacity: 0; + transform: translateX(28px) scale(0.985); + transition: + opacity 0.18s ease, + transform 0.18s ease, + border-color 0.18s ease; +} + +.notification::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.08), + transparent 24% + ); pointer-events: none; } -/* Notification Container */ -.notification-container { - position: fixed; - top: 120px; - right: 20px; - z-index: 1000; - width: 350px; - pointer-events: auto; +.notification.show { + opacity: 1; + transform: translateX(0) scale(1); } -/* Individual Notification */ -.notification { - background: rgba(15, 20, 30, 0.9); - border: 1px solid rgba(100, 150, 200, 0.4); - border-left: 3px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; - box-shadow: - 0 0 20px rgba(100, 150, 200, 0.15), - 0 4px 16px rgba(0, 0, 0, 0.8); - margin-bottom: 10px; - padding: 1rem 1.25rem; - width: 100%; - transform: translateX(100%); - transition: all 0.15s ease; +.notification.hide { + opacity: 0; + transform: translateX(32px) scale(0.985); +} + +.notification-inner { position: relative; - overflow: hidden; - - &.show { - transform: translateX(0); - } - - &.hide { - transform: translateX(100%); - opacity: 0; - } - - /* Notification Types */ - &.success { - border-left-color: rgba(100, 200, 150, 0.6); - - .notification-title { - color: rgba(150, 255, 200, 0.9); - } - - .notification-progress-bar { - background: rgba(100, 200, 150, 0.8); - animation: progress 5s linear forwards; - } - } - - &.danger { - border-left-color: rgba(220, 100, 100, 0.6); - - .notification-title { - color: rgba(255, 150, 150, 0.9); - } - - .notification-progress-bar { - background: rgba(220, 100, 100, 0.8); - animation: progress 5s linear forwards; - } - } - - &.warning { - border-left-color: rgba(200, 150, 100, 0.6); - - .notification-title { - color: rgba(255, 200, 150, 0.9); - } - - .notification-progress-bar { - background: rgba(200, 150, 100, 0.8); - animation: progress 5s linear forwards; - } - } - - &.info { - border-left-color: rgba(100, 150, 200, 0.6); - - .notification-title { - color: rgba(150, 200, 255, 0.9); - } - - .notification-progress-bar { - background: rgba(100, 150, 200, 0.8); - animation: progress 5s linear forwards; - } - } + display: block; + padding: 0; +} + +.notification-body { + display: flex; + flex-direction: column; + gap: 7px; + min-width: 0; + padding: 10px 11px 11px; + background: var(--body-bg); +} + +.notification-meta, +.notification-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; } -/* Notification Content */ .notification-header { display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; + flex-direction: column; + gap: 6px; + padding: 10px 11px; + background: var(--header-bg); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.notification-meta { + color: var(--body-muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.notification-badge { + padding: 3px 6px; + border-radius: 999px; + border: 1px solid currentColor; + background: rgba(34, 51, 74, 0.08); +} + +.notification-time { + color: var(--body-faint); } .notification-title { - font-weight: 600; - font-size: 0.875rem; + color: var(--header-text); + font-size: 14px; + font-weight: 700; + line-height: 1.1; +} + +.notification-subtitle { + color: var(--header-muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.12em; text-transform: uppercase; - letter-spacing: 0.5px; - flex: 1; - color: rgba(200, 220, 255, 1); } .notification-message { - color: rgba(140, 160, 180, 0.9); - font-size: 0.8rem; - line-height: 1.4; - word-wrap: break-word; - margin-bottom: 0.5rem; + color: var(--body-text); + font-size: 12px; + line-height: 1.35; + word-break: break-word; +} + +.notification-footer { + color: var(--body-faint); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } -/* Progress bar for auto-dismiss */ .notification-progress { - position: absolute; - bottom: 0; - left: 0; - height: 3px; - background: rgba(15, 20, 30, 0.5); - width: 100%; - border-radius: 0 0 4px 4px; + height: 4px; + background: rgba(34, 51, 74, 0.16); } .notification-progress-bar { height: 100%; - width: 0%; - transform-origin: left; - border-radius: 0 0 4px 4px; - transition: width linear; + width: 100%; + background: currentColor; + opacity: 0.95; + transform: scaleX(1); + transform-origin: left center; + transition: transform linear; } -/* Responsive Design */ -@media (max-width: 768px) { - .notification-container { - left: 20px; - right: 20px; - width: auto; - } - - .notification { - transform: translateY(-100%); - - &.show { - transform: translateY(0); - } - - &.hide { - transform: translateY(-100%); - } - } +.notification.is-persistent .notification-progress { + display: none; } -@media (max-width: 500px) { - .notification-container { - width: calc(100vw - 40px); +.notification.success { + color: var(--success); + border-color: rgba(109, 226, 179, 0.24); +} + +.notification.success .notification-badge { + background-color: var(--success-soft); +} + +.notification.danger { + color: var(--danger); + border-color: rgba(255, 123, 123, 0.24); +} + +.notification.danger .notification-badge { + background-color: var(--danger-soft); +} + +.notification.warning { + color: var(--warning); + border-color: rgba(255, 211, 107, 0.24); +} + +.notification.warning .notification-badge { + background-color: var(--warning-soft); +} + +.notification.info { + color: var(--info); + border-color: rgba(120, 185, 255, 0.24); +} + +.notification.info .notification-badge { + background-color: var(--info-soft); +} + +@media (max-width: 720px) { + :root { + --hud-top: 18px; + --hud-right: 12px; + } + + .notifications-hud { + width: calc(100vw - 16px); + max-width: calc(100vw - 16px); } } diff --git a/arma/client/addons/org/README.md b/arma/client/addons/org/README.md index 1c007ba..a464e97 100644 --- a/arma/client/addons/org/README.md +++ b/arma/client/addons/org/README.md @@ -1,4 +1,85 @@ -forge_client_org -=================== +# forge_client_org -Description for this addon +Player organization UI and client integration. + +## UI Login Contract + +The web UI sends the following request through `A3API.SendAlert`: + +```json +{ + "event": "org::login::request", + "data": { + "email": "admin@spearnet.mil", + "password": "secret" + } +} +``` + +On success, SQF should call the browser bridge with: + +```sqf +private _payload = createHashMapFromArray [ + ["session", createHashMapFromArray [ + ["actorName", name player], + ["role", "Leader"] + ]], + ["portalData", createHashMapFromArray [ + ["org", createHashMapFromArray [ + ["name", "Black Rifle Company"], + ["tag", "BRC-0160566824"], + ["type", "Private Military Company"], + ["status", "Operational"], + ["headquarters", "Georgetown Command Annex"], + ["owner", "Jacob Schmidt"] + ]], + ["funds", 482750], + ["reputation", 72], + ["members", [ + createHashMapFromArray [["name", "Jacob Schmidt"]], + createHashMapFromArray [["name", "Mara Velez"]] + ]], + ["fleet", [ + createHashMapFromArray [ + ["name", "UH-80 Ghost Hawk"], + ["type", "helicopter"], + ["status", "Ready"], + ["damage", "16%"] + ] + ]], + ["assets", [ + createHashMapFromArray [ + ["name", "First Aid Kits"], + ["type", "items"], + ["quantity", "36"] + ] + ]], + ["activity", []], + ["roadmap", []] + ]] +]; + +_control ctrlWebBrowserAction [ + "ExecJS", + format ["OrgUIBridge.receiveLoginSuccess(%1)", toJSON _payload] +]; +``` + +On failure: + +```sqf +private _payload = createHashMapFromArray [ + ["message", "Invalid credentials."] +]; + +_control ctrlWebBrowserAction [ + "ExecJS", + format ["OrgUIBridge.receiveLoginFailure(%1)", toJSON _payload] +]; +``` + +Current implementation: + +- `fnc_handleUIEvents.sqf` now handles `org::login::request` +- success hydrates the portal with `session` + `portalData` +- failure returns a single `message` string for inline UI feedback diff --git a/arma/client/addons/org/XEH_PREP.hpp b/arma/client/addons/org/XEH_PREP.hpp index d83118a..7d71bae 100644 --- a/arma/client/addons/org/XEH_PREP.hpp +++ b/arma/client/addons/org/XEH_PREP.hpp @@ -1,3 +1,4 @@ PREP(handleUIEvents); -PREP(initOrgClass); +PREP(initClass); +PREP(initUIBridge); PREP(openUI); diff --git a/arma/client/addons/org/XEH_postInitClient.sqf b/arma/client/addons/org/XEH_postInitClient.sqf index e6c9147..3f9e4d9 100644 --- a/arma/client/addons/org/XEH_postInitClient.sqf +++ b/arma/client/addons/org/XEH_postInitClient.sqf @@ -1,6 +1,7 @@ #include "script_component.hpp" -if (isNil QGVAR(OrgClass)) then { [] call FUNC(initOrgClass); }; +if (isNil QGVAR(OrgClass)) then { call FUNC(initClass); }; +if (isNil QGVAR(OrgUIBridge)) then { call FUNC(initUIBridge); }; [QGVAR(initOrg), { GVAR(OrgClass) call ["init", []]; @@ -10,12 +11,38 @@ if (isNil QGVAR(OrgClass)) then { [] call FUNC(initOrgClass); }; params [["_data", createHashMap, [createHashMap]]]; GVAR(OrgClass) call ["sync", [_data, true]]; + GVAR(OrgUIBridge) call ["refreshPortal", []]; }] call CFUNC(addEventHandler); [QGVAR(responseSyncOrg), { params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; GVAR(OrgClass) call ["sync", [_data, _jip]]; + GVAR(OrgUIBridge) call ["refreshPortal", []]; +}] call CFUNC(addEventHandler); + +[QGVAR(responseCreateOrg), { + params [["_payload", createHashMap, [createHashMap]]]; + + GVAR(OrgUIBridge) call ["handleCreateResponse", [_payload]]; +}] call CFUNC(addEventHandler); + +[QGVAR(responseDisbandOrg), { + params [["_payload", createHashMap, [createHashMap]]]; + + GVAR(OrgUIBridge) call ["handleDisbandResponse", [_payload]]; +}] call CFUNC(addEventHandler); + +[QGVAR(responseLeaveOrg), { + params [["_payload", createHashMap, [createHashMap]]]; + + GVAR(OrgUIBridge) call ["handleLeaveResponse", [_payload]]; +}] call CFUNC(addEventHandler); + +[QGVAR(responseCreditLine), { + params [["_payload", createHashMap, [createHashMap]]]; + + GVAR(OrgUIBridge) call ["handleCreditLineResponse", [_payload]]; }] call CFUNC(addEventHandler); [{ diff --git a/arma/client/addons/org/config.cpp b/arma/client/addons/org/config.cpp index 2b01e51..3aca435 100644 --- a/arma/client/addons/org/config.cpp +++ b/arma/client/addons/org/config.cpp @@ -8,6 +8,7 @@ class CfgPatches { name = COMPONENT_NAME; requiredVersion = REQUIRED_VERSION; requiredAddons[] = { + "forge_client_common", "forge_client_main" }; units[] = {}; diff --git a/arma/client/addons/org/functions/fnc_handleUIEvents.sqf b/arma/client/addons/org/functions/fnc_handleUIEvents.sqf index ce70c0f..a9657c3 100644 --- a/arma/client/addons/org/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/org/functions/fnc_handleUIEvents.sqf @@ -11,7 +11,7 @@ * None * * Example: - * [] call forge_client_org_fnc_handleUIEvents; + * call forge_client_org_fnc_handleUIEvents; * * Public: No */ @@ -21,12 +21,29 @@ params ["_control", "_isConfirmDialog", "_message"]; private _alert = fromJSON _message; private _event = _alert get "event"; private _data = _alert get "data"; -private _display = displayChild findDisplay 46; diag_log format ["[FORGE:Client:Org] Handling UI event: %1 with data: %2", _event, _data]; switch (_event) do { - case "org::close": { _display closeDisplay 1; }; + case "org::close": { closeDialog 1; }; + case "org::login::request": { + GVAR(OrgUIBridge) call ["handleLoginRequest", [_control]]; + }; + case "org::create::request": { + GVAR(OrgUIBridge) call ["handleCreateRequest", [_control, _data]]; + }; + case "org::disband::request": { + GVAR(OrgUIBridge) call ["requestDisband", []]; + }; + case "org::leave::request": { + GVAR(OrgUIBridge) call ["requestLeave", []]; + }; + case "org::credit::request": { + GVAR(OrgUIBridge) call ["requestCreditLine", [_data]]; + }; + case "org::ready": { + GVAR(OrgUIBridge) call ["handleReady", [_control]]; + }; default { hint format ["Unhandled UI event: %1", _event]; }; }; diff --git a/arma/client/addons/org/functions/fnc_initClass.sqf b/arma/client/addons/org/functions/fnc_initClass.sqf new file mode 100644 index 0000000..dab354d --- /dev/null +++ b/arma/client/addons/org/functions/fnc_initClass.sqf @@ -0,0 +1,181 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initClass.sqf + * Author: IDSolutions + * Date: 2026-02-13 + * Last Update: 2026-02-13 + * Public: No + * + * Description: + * Initializes the org class. + * + * Arguments: + * None + * + * Return Value: + * Org class object [HASHMAP OBJECT] + * + * Examples: + * call forge_client_org_fnc_initClass + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(OrgBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "OrgBaseClass"], + ["#create", compileFinal { + _self set ["uid", getPlayerUID player]; + _self set ["org", createHashMap]; + _self set ["isLoaded", false]; + _self set ["lastSave", time]; + + private _org = createHashMap; + _org set ["id", ""]; + _org set ["owner", ""]; + _org set ["name", ""]; + _org set ["funds", 0]; + _org set ["reputation", 0]; + _org set ["credit_lines", createHashMap]; + _org set ["assets", createHashMap]; + _org set ["fleet", createHashMap]; + _org set ["members", createHashMap]; + + _self set ["org", _org]; + }], + ["init", compileFinal { + private _uid = _self get "uid"; + private _org = _self get "org"; + + [SRPC(org,requestInitOrg), [_uid, _org]] call CFUNC(serverEvent); + + systemChat format ["Org loaded for %1", (name player)]; + diag_log "[FORGE:Client:Org] Org Class Initialized!"; + }], + ["save", compileFinal { + params [["_sync", false, [false]]]; + + private _uid = _self get "uid"; + [SRPC(org,requestSaveOrg), [_uid, _sync]] call CFUNC(serverEvent); + + _self set ["lastSave", time]; + }], + ["sync", compileFinal { + params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; + + private _isLoaded = _self get "isLoaded"; + private _org = _self get "org"; + + { _org set [_x, _y]; } forEach _data; + _self set ["org", _org]; + + if !(_isLoaded) then { _self set ["isLoaded", true]; }; + diag_log "[FORGE:Client:Org] Sync completed"; + }], + ["buildPortalPayload", compileFinal { + private _orgData = _self get "org"; + + private _name = _orgData get "name"; + private _id = _orgData get "id"; + private _ownerUid = _orgData get "owner"; + private _funds = _orgData get "funds"; + private _reputation = _orgData get "reputation"; + private _creditLinesRaw = _orgData getOrDefault ["credit_lines", createHashMap]; + private _assetsRaw = _orgData get "assets"; + private _fleetRaw = _orgData get "fleet"; + private _membersRaw = _orgData get "members"; + private _isDefaultOrg = (_orgData getOrDefault ["default", false]) + || {toLower _id isEqualTo "default"} + || {toLower _ownerUid isEqualTo "server"}; + + private _playerName = name player; + private _playerUid = getPlayerUID player; + private _playerVar = vehicleVarName player; + private _sessionRole = "Member"; + private _sessionIsCeo = _isDefaultOrg && {_playerVar isEqualTo "ceo"}; + private _ownerName = ["", "Server"] select (toLower _ownerUid isEqualTo "server"); + + private _membersList = []; + { + private _memberData = _y; + private _memberName = _memberData getOrDefault ["name", "Unknown"]; + private _memberUid = _memberData getOrDefault ["uid", ""]; + + if (_memberUid isEqualTo _ownerUid && {_ownerName isEqualTo ""}) then { _ownerName = _memberName; }; + if (_memberUid isEqualTo _playerUid) then { _sessionRole = "Member"; }; + + _membersList pushBack (createHashMapFromArray [ + ["uid", _memberUid], + ["name", _memberName] + ]); + } forEach _membersRaw; + + if (_ownerName isEqualTo "" && { _ownerUid isEqualTo _playerUid }) then { _ownerName = _playerName; }; + if (_ownerName isEqualTo "" && { _ownerUid isNotEqualTo "" }) then { _ownerName = "Unknown Owner"; }; + if (_ownerUid isEqualTo _playerUid) then { _sessionRole = "Leader"; }; + + private _assetsList = []; + { + private _assetData = _y; + _assetsList pushBack (createHashMapFromArray [ + ["name", _assetData getOrDefault ["name", "Unknown Asset"]], + ["type", _assetData getOrDefault ["type", "items"]], + ["quantity", str (_assetData getOrDefault ["quantity", 0])] + ]); + } forEach _assetsRaw; + + private _fleetList = []; + { + private _vehicleData = _y; + _fleetList pushBack (createHashMapFromArray [ + ["name", _vehicleData getOrDefault ["name", "Unknown Vehicle"]], + ["type", _vehicleData getOrDefault ["type", "other"]], + ["status", _vehicleData getOrDefault ["status", "Unknown"]], + ["damage", _vehicleData getOrDefault ["damage", "0%"]] + ]); + } forEach _fleetRaw; + + private _creditLinesList = []; + { + private _creditLineData = _y; + _creditLinesList pushBack (createHashMapFromArray [ + ["uid", _creditLineData getOrDefault ["uid", _x]], + ["member", _creditLineData getOrDefault ["name", "Unknown Member"]], + ["amount", _creditLineData getOrDefault ["amount", 0]] + ]); + } forEach _creditLinesRaw; + + createHashMapFromArray [ + ["session", createHashMapFromArray [ + ["actorName", _playerName], + ["actorUid", _playerUid], + ["role", _sessionRole], + ["ceo", _sessionIsCeo] + ]], + ["portalData", createHashMapFromArray [ + ["org", createHashMapFromArray [ + ["name", _name], + ["tag", _id], + ["owner", _ownerName], + ["ownerUid", _ownerUid], + ["isDefault", _isDefaultOrg] + ]], + ["funds", _funds], + ["reputation", _reputation], + ["creditLines", _creditLinesList], + ["members", _membersList], + ["fleet", _fleetList], + ["assets", _assetsList], + ["activity", []] + ]] + ] + }], + ["get", compileFinal { + params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; + + private _org = _self get "org"; + _org getOrDefault [_key, _default]; + }] +]; + +GVAR(OrgClass) = createHashMapObject [GVAR(OrgBaseClass)]; +GVAR(OrgClass) diff --git a/arma/client/addons/org/functions/fnc_initOrgClass.sqf b/arma/client/addons/org/functions/fnc_initOrgClass.sqf deleted file mode 100644 index 53c2fb7..0000000 --- a/arma/client/addons/org/functions/fnc_initOrgClass.sqf +++ /dev/null @@ -1,82 +0,0 @@ -#include "..\script_component.hpp" - -/* - * Author: IDSolutions - * Initializes the org class. - * - * Arguments: - * None - * - * Return Value: - * None - * - * Examples: - * [] call forge_client_org_fnc_initOrgClass - * - * Public: Yes - */ - -#pragma hemtt ignore_variables ["_self"] -GVAR(OrgClass) = createHashMapObject [[ - ["#type", "IOrgClass"], - ["#create", { - _self set ["uid", getPlayerUID player]; - _self set ["org", createHashMap]; - _self set ["isLoaded", false]; - _self set ["lastSave", time]; - - private _org = createHashMap; - _org set ["id", ""]; - _org set ["owner", ""]; - _org set ["name", ""]; - _org set ["funds", 0]; - _org set ["reputation", 0]; - _org set ["assets", createHashMap]; - _org set ["members", createHashMap]; - - _self set ["org", _org]; - }], - ["init", { - private _uid = _self get "uid"; - private _org = _self get "org"; - - [SRPC(org,requestInitOrg), [_uid, _org]] call CFUNC(serverEvent); - - systemChat format ["Org loaded for %1", (name player)]; - diag_log "[FORGE:Client:Org] Org Class Initialized!"; - }], - ["save", { - params [["_sync", false, [false]]]; - - private _uid = _self get "uid"; - [SRPC(org,requestSaveOrg), [_uid, _sync]] call CFUNC(serverEvent); - - _self set ["lastSave", time]; - }], - ["sync", { - params [["_data", createHashMap, [createHashMap]], ["_jip", false, [false]]]; - - private _isLoaded = _self get "isLoaded"; - private _org = _self get "org"; - - if (_data isEqualTo createHashMap) exitWith { - diag_log "[FORGE:Client:Org] Empty data received for sync, skipping."; - }; - - { _org set [_x, _y]; } forEach _data; - - _self set ["org", _org]; - - if !(_isLoaded) then { _self set ["isLoaded", true]; }; - diag_log "[FORGE:Client:Org] Sync completed"; - }], - ["get", { - params [["_key", "", [""]], ["_default", nil, [[], "", 0, false, createHashMap]]]; - - private _org = _self get "org"; - _org getOrDefault [_key, _default]; - }] -]]; - -SETVAR(player,FORGE_OrgClass,GVAR(OrgClass)); -GVAR(OrgClass) diff --git a/arma/client/addons/org/functions/fnc_initUIBridge.sqf b/arma/client/addons/org/functions/fnc_initUIBridge.sqf new file mode 100644 index 0000000..cfc5875 --- /dev/null +++ b/arma/client/addons/org/functions/fnc_initUIBridge.sqf @@ -0,0 +1,166 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initUIBridge.sqf + * Author: IDSolutions + * Date: 2026-03-10 + * Last Update: 2026-03-13 + * Public: No + * + * Description: + * Initializes the org UI bridge for browser control state and event routing. + * + * Arguments: + * None + * + * Return Value: + * Org UI bridge object [HASHMAP OBJECT] + * + * Examples: + * call forge_client_org_fnc_initUIBridge + */ + +#pragma hemtt ignore_variables ["_self"] +private _webUIDeclarations = call EFUNC(common,initWebUIBridge); +private _webUIBridgeDeclaration = _webUIDeclarations get "bridgeDeclaration"; + +GVAR(OrgUIBridgeBaseClass) = compileFinal createHashMapFromArray [ + ["#base", _webUIBridgeDeclaration], + ["#type", "OrgUIBridgeBaseClass"], + ["setPendingBrowserControl", compileFinal { + params [["_control", controlNull, [controlNull]]]; + + _self set ["pendingBrowserControl", _control]; + _control + }], + ["consumePendingBrowserControl", compileFinal { + private _control = _self getOrDefault ["pendingBrowserControl", controlNull]; + _self set ["pendingBrowserControl", controlNull]; + + _control + }], + ["getActiveBrowserControl", compileFinal { + private _display = uiNamespace getVariable ["RscOrg", displayNull]; + if (isNull _display) exitWith { + _self call ["setActiveBrowserControl", [controlNull]]; + controlNull + }; + + private _control = _display displayCtrl 1003; + _self call ["setActiveBrowserControl", [_control]]; + _control + }], + ["handleLoginRequest", compileFinal { + params [["_control", controlNull, [controlNull]]]; + + private _orgData = GVAR(OrgClass) get "org"; + private _orgId = _orgData getOrDefault ["id", ""]; + private _orgName = _orgData getOrDefault ["name", ""]; + + if (_orgId isEqualTo "" && { _orgName isEqualTo "" }) exitWith { + _self call ["sendEvent", ["org::login::failure", createHashMapFromArray [ + ["message", "No organization data is available for this player."] + ], _control]]; + }; + + _self call ["sendEvent", ["org::login::success", GVAR(OrgClass) call ["buildPortalPayload", []], _control]]; + }], + ["handleCreateRequest", compileFinal { + params [["_control", controlNull, [controlNull]], ["_data", createHashMap, [createHashMap]]]; + + private _orgName = _data getOrDefault ["orgName", ""]; + if (_orgName isEqualTo "") exitWith { + _self call ["sendEvent", ["org::create::failure", createHashMapFromArray [ + ["message", "Enter an organization name."] + ], _control]]; + }; + + _self call ["setPendingBrowserControl", [_control]]; + [SRPC(org,requestCreateOrg), [getPlayerUID player, _orgName]] call CFUNC(serverEvent); + }], + ["handleCreateResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _control = _self call ["consumePendingBrowserControl", []]; + private _success = _payload getOrDefault ["success", false]; + if (!_success) exitWith { + if (isNull _control) exitWith {}; + + _self call ["sendEvent", ["org::create::failure", createHashMapFromArray [ + ["message", _payload getOrDefault ["message", "Organization registration failed."]] + ], _control]]; + }; + + private _orgData = _payload getOrDefault ["org", createHashMap]; + GVAR(OrgClass) call ["sync", [_orgData, true]]; + + if (isNull _control) exitWith {}; + _self call ["sendEvent", ["org::create::success", GVAR(OrgClass) call ["buildPortalPayload", []], _control]]; + }], + ["handleDisbandResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _eventName = if (_payload getOrDefault ["success", false]) then { + ["org::portal::revoked", "org::disband::success"] select (_payload getOrDefault ["requester", false]) + } else { + "org::disband::failure" + }; + + _self call ["sendEvent", [_eventName, _payload]]; + }], + ["handleLeaveResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _eventName = [ + "org::leave::failure", + "org::leave::success" + ] select (_payload getOrDefault ["success", false]); + + _self call ["sendEvent", [_eventName, _payload]]; + }], + ["handleCreditLineResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _eventName = [ + "org::credit::failure", + "org::credit::success" + ] select (_payload getOrDefault ["success", false]); + + _self call ["sendEvent", [_eventName, _payload]]; + + if (_payload getOrDefault ["success", false]) then { + private _memberUid = _payload getOrDefault ["memberUid", ""]; + if (_memberUid isNotEqualTo "") then { + _self call ["sendEvent", ["org::member::creditUpdated", createHashMapFromArray [ + ["amount", _payload getOrDefault ["amount", 0]], + ["memberName", _payload getOrDefault ["memberName", ""]], + ["memberUid", _memberUid] + ]]]; + }; + }; + }], + ["requestDisband", compileFinal { + [SRPC(org,requestDisbandOrg), [getPlayerUID player]] call CFUNC(serverEvent); + }], + ["requestLeave", compileFinal { + [SRPC(org,requestLeaveOrg), [getPlayerUID player]] call CFUNC(serverEvent); + }], + ["requestCreditLine", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _memberUid = _data getOrDefault ["memberUid", ""]; + private _memberName = _data getOrDefault ["memberName", ""]; + private _amount = _data getOrDefault ["amount", 0]; + + [SRPC(org,requestAssignCreditLine), [getPlayerUID player, _memberUid, _memberName, _amount]] call CFUNC(serverEvent); + }], + ["refreshPortal", compileFinal { + private _control = _self call ["getActiveBrowserControl", []]; + if (isNull _control) exitWith { false }; + + _self call ["sendEvent", ["org::sync", GVAR(OrgClass) call ["buildPortalPayload", []], _control]] + }] +]; + +GVAR(OrgUIBridge) = createHashMapObject [GVAR(OrgUIBridgeBaseClass)]; +GVAR(OrgUIBridge) diff --git a/arma/client/addons/org/functions/fnc_openUI.sqf b/arma/client/addons/org/functions/fnc_openUI.sqf index f36395d..7506dd4 100644 --- a/arma/client/addons/org/functions/fnc_openUI.sqf +++ b/arma/client/addons/org/functions/fnc_openUI.sqf @@ -11,13 +11,13 @@ * None * * Example: - * [] call forge_client_org_fnc_openUI; + * call forge_client_org_fnc_openUI; * * Public: No */ -private _display = (findDisplay 46) createDisplay "RscOrg"; -private _ctrl = (_display displayCtrl 1003); +private _display = createDialog ["RscOrg", true]; +private _ctrl = _display displayCtrl 1003; _ctrl ctrlAddEventHandler ["JSDialog", { params ["_control", "_isConfirmDialog", "_message"]; diff --git a/arma/client/addons/org/ui/_site.7z b/arma/client/addons/org/ui/_site.7z deleted file mode 100644 index 22e7cf6..0000000 Binary files a/arma/client/addons/org/ui/_site.7z and /dev/null differ diff --git a/arma/client/addons/org/ui/_site/index.html b/arma/client/addons/org/ui/_site/index.html index b68277e..05d3f52 100644 --- a/arma/client/addons/org/ui/_site/index.html +++ b/arma/client/addons/org/ui/_site/index.html @@ -1,243 +1 @@ - - - - - - - Organization Dashboard - - - - - - -
- -
- -
-

Organization Name

-

FACTION-001

-
-
- - -
-
- - -
- -
-
-

Overview

-
Active
-
-
-
-
- Total Members - 24 -
-
- Online Now - 8 -
-
- Org Balance - $125,000 -
-
- Reputation - Level 5 -
-
-
-
- - -
-
-

Members Online

- 8 -
-
-
-
-
-
- John Doe - Leader -
-
-
-
-
- Jane Smith - Officer -
-
-
-
-
- Mike Johnson - Member -
-
-
-
-
- Sarah Wilson - Member -
-
-
-
-
- - -
-
-

Recent Activity

-
-
-
-
-
2m ago
-
Mike Johnson completed mission "Alpha Strike"
-
-
-
15m ago
-
Jane Smith deposited $5,000 to org bank
-
-
-
1h ago
-
New member Alex Brown joined the organization
-
-
-
2h ago
-
Organization captured territory: Zone-7
-
-
-
-
- - -
-
-

Assets

-
-
-
-
- 🏢 -
- Headquarters - Downtown -
-
-
- 🚁 -
- Helicopters - 3 units -
-
-
- 🚗 -
- Vehicles - 12 units -
-
-
- 📦 -
- Storage Units - 5 locations -
-
-
-
-
- - -
-
-

Active Missions

- 3 -
-
-
-
-
- Supply Run - High Priority -
-
Deliver supplies to northern outpost
-
-
-
-
- 65% -
-
-
-
- Recon Operation - Medium Priority -
-
Scout enemy positions in Zone-4
-
-
-
-
- 30% -
-
-
-
- Territory Defense - Low Priority -
-
Maintain control of captured zones
-
-
-
-
- 90% -
-
-
-
-
-
-
- - - - - +ORBIS - Global Organization Network
\ No newline at end of file diff --git a/arma/client/addons/org/ui/_site/org-ui.css b/arma/client/addons/org/ui/_site/org-ui.css new file mode 100644 index 0000000..2972aa6 --- /dev/null +++ b/arma/client/addons/org/ui/_site/org-ui.css @@ -0,0 +1 @@ +:root{--bg-app:#fdfcf8;--bg-surface:#fff;--bg-surface-hover:#f1f5f9;--primary:#475569;--primary-hover:#1e293b;--text-main:#1f2937;--text-muted:#64748b;--text-inverse:#f8fafc;--border:#e2e8f0;--radius:8px;--shadow:0 1px 3px 0 #0000001a, 0 1px 2px -1px #0000001a;--footer-bg:#1e293b}html,body{height:100%}*,:before,:after{box-sizing:border-box}body{background:var(--bg-app);color:var(--text-main);margin:0;padding:0;font-family:Inter,system-ui,-apple-system,sans-serif;line-height:1.6;overflow:hidden}#app{height:100vh;overflow:hidden}.app-shell{flex-direction:column;height:100vh;display:flex;overflow:hidden}#org-portal-frame-root{flex-direction:column;flex:auto;min-height:0;display:flex;overflow:hidden}main{overscroll-behavior:contain;flex-direction:column;flex:auto;min-height:0;display:flex;overflow:auto}.container{box-sizing:border-box;flex-direction:column;flex:1;width:100%;max-width:1200px;margin:0 auto;padding:2rem;display:flex}.header{text-align:center;border-bottom:1px solid var(--border);margin-bottom:3rem;padding-bottom:2rem}.header h1{letter-spacing:-.025em;color:var(--primary-hover);margin-bottom:.5rem;font-size:2.5rem;font-weight:700}.header p{color:var(--text-muted);font-size:1.1rem}.card{background:var(--bg-surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);text-align:center;padding:2rem}.card h2{color:var(--primary-hover);margin-top:0;font-size:1.8rem}button{background:var(--primary);color:#fff;border-radius:var(--radius);cursor:pointer;border:none;padding:.75rem 1.5rem;font-size:1rem;font-weight:500;transition:all .2s}button:hover{background:var(--primary-hover);transform:translateY(-1px);box-shadow:0 4px 6px -1px #0000001a}button:disabled{cursor:not-allowed;opacity:.65;box-shadow:none;transform:none}button+button{margin-left:1rem}.footer{background:var(--footer-bg);color:var(--text-inverse);margin-top:auto;display:block}.footer .wrapper{box-sizing:border-box;grid-template-columns:1fr 1fr;gap:4rem;width:100%;max-width:1200px;margin:0 auto;padding:3rem 2rem;display:grid}.footer h3{color:var(--text-inverse);text-transform:uppercase;letter-spacing:.1em;border-bottom:1px solid #475569;margin-bottom:1.5rem;margin-right:1rem;padding-bottom:.5rem;font-size:.85rem;font-weight:700}.footer ul li{color:#cbd5e1;cursor:pointer;margin-bottom:.75rem;font-size:.95rem;transition:color .2s}.footer ul li:hover{color:#fff}.org-secondary-btn{background:var(--bg-surface);color:var(--text-main);border:1px solid var(--border)}.org-secondary-btn:hover{background:var(--bg-surface-hover);color:var(--text-main)}.org-danger-btn{color:#fef2f2;background:#7f1d1d}.org-danger-btn:hover{background:#991b1b}.org-icon-btn{justify-content:center;align-items:center;width:2.5rem;height:2.5rem;padding:0;display:inline-flex}.org-icon{width:1rem;height:1rem}.org-page-header{text-align:left;margin-bottom:0}.org-page-heading{flex-direction:column;gap:.35rem;display:flex}.org-page-kicker{text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);font-size:.7rem;font-weight:600}.org-page-title{margin:0}.org-page-subtitle{color:var(--text-muted);margin:0;font-size:.9rem}.org-page-meta{color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;font-size:.75rem}@media (width<=960px){.container{padding:1.5rem}.header{margin-bottom:2rem;padding-bottom:1.5rem}.header h1{font-size:2rem}.footer .wrapper{grid-template-columns:1fr}.org-page-heading{gap:.3rem}} \ No newline at end of file diff --git a/arma/client/addons/org/ui/_site/org-ui.js b/arma/client/addons/org/ui/_site/org-ui.js new file mode 100644 index 0000000..375a7ff --- /dev/null +++ b/arma/client/addons/org/ui/_site/org-ui.js @@ -0,0 +1 @@ +!function(){const e=window.ForgeWebUI,n=window.RegistryApp=window.RegistryApp||{},r=window.OrgPortal=window.OrgPortal||{};n.runtime=e,r.runtime=e,window.AppRuntime=e}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{createSignal:n}=e.runtime;e.store=new class{constructor(){[this.getView,this.setView]=n("home"),[this.getIsAuthenticating,this.setIsAuthenticating]=n(!1),[this.getLoginError,this.setLoginError]=n(""),[this.getIsCreating,this.setIsCreating]=n(!1),[this.getCreateError,this.setCreateError]=n("")}startLogin(){this.setLoginError(""),this.setIsAuthenticating(!0)}startCreate(){this.setCreateError(""),this.setIsCreating(!0)}failLogin(e){this.setIsAuthenticating(!1),this.setLoginError(e||"Authentication failed.")}failCreate(e){this.setIsCreating(!1),this.setCreateError(e||"Organization registration failed.")}hydratePortal(e){const n=window.OrgPortal&&window.OrgPortal.data?window.OrgPortal.data:null,r=window.OrgPortal&&window.OrgPortal.store?window.OrgPortal.store:null,t=e&&e.portalData?e.portalData:null,a=e&&e.session?e.session:null;return!!(n&&"function"==typeof n.applyLoginPayload&&r&&"function"==typeof r.hydrateFromPayload&&t&&a)&&(n.applyLoginPayload(e),r.hydrateFromPayload(e),!0)}completeLogin(e){this.hydratePortal(e)?(this.setLoginError(""),this.setIsAuthenticating(!1),this.setView("portal")):this.failLogin("Login response was missing portal data.")}completeCreate(e){this.hydratePortal(e)?(this.setCreateError(""),this.setIsCreating(!1),this.setView("portal")):this.failCreate("Organization registration response was missing portal data.")}}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},n=e.store,r=window.ForgeWebUI.createBridge({closeEvent:"org::close",globalName:"ForgeBridge",readyEvent:"org::ready"});function t(e,n){return r.send(e,n)}r.on("org::login::success",e=>{n.completeLogin(e)}),r.on("org::login::failure",e=>{n.failLogin(e.message||"Authentication failed.")}),r.on("org::create::success",e=>{n.completeCreate(e)}),r.on("org::create::failure",e=>{n.failCreate(e.message||"Organization registration failed.")}),r.on("org::sync",e=>{n&&"function"==typeof n.hydratePortal&&n.hydratePortal(e)}),r.on("org::credit::success",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("success",e.message||"Credit line assigned.")}),r.on("org::credit::failure",e=>{const n=window.OrgPortal;n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to assign credit line.")}),r.on("org::member::creditUpdated",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setCreditLines(n=>{const r={amount:e.amount||0,member:e.memberName||"",uid:e.memberUid||""},t=n.findIndex(e=>e.uid===r.uid);return-1===t?[...n,r]:n.map((e,n)=>n===t?r:e)})}),r.on("org::disband::success",()=>{const e=window.OrgPortal;e&&e.store&&(e.store.setModal(null),e.store.setOrgDisbanded(!0))}),r.on("org::disband::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Organization disbanding failed.")}),r.on("org::leave::success",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"You have left the organization."),n.setView("home")}),r.on("org::leave::failure",e=>{const n=window.OrgPortal;n&&n.store&&n.store.setModal(null),n&&n.actions&&n.actions.showTreasuryNotice("error",e.message||"Unable to leave the organization.")}),r.on("org::portal::revoked",e=>{const r=window.OrgPortal;r&&r.store&&r.store.setModal(null),n.failLogin(e.message||"Organization access is no longer available."),n.setView("home")}),e.bridge={close:r.close,ready:r.ready,receive:r.receive,requestLogin:function(e){n.startLogin(),t("org::login::request",e)||n.failLogin("Arma login bridge is unavailable.")},requestCreateOrg:function(e){n.startCreate(),t("org::create::request",e)||n.failCreate("Arma registration bridge is unavailable.")},requestDisbandOrg:function(){if(t("org::disband::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma disband bridge is unavailable.")},requestLeaveOrg:function(){if(t("org::leave::request",{}))return;const e=window.OrgPortal;e&&e.actions&&e.actions.showTreasuryNotice("error","Arma leave bridge is unavailable.")},requestCreditLine:function(e){if(t("org::credit::request",e))return!0;const n=window.OrgPortal;return n&&n.actions&&n.actions.showTreasuryNotice("error","Arma credit line bridge is unavailable."),!1},sendEvent:t}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},n={type:"Organization",status:"Operational",headquarters:"ArmA Verse"};function r(e){return JSON.parse(JSON.stringify(e))}function t(e,n){Object.keys(e).forEach(n=>delete e[n]),Object.assign(e,r(n))}function a(e,n){e.splice(0,e.length,...r(n))}e.data={portalData:{org:Object.assign({name:"",tag:"",owner:"",ownerUid:"",isDefault:!1},n),funds:0,reputation:0,creditLines:[],members:[],fleet:[],assets:[],activity:[],roadmap:[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}]},session:{actorName:"",actorUid:"",role:"",ceo:!1},applyLoginPayload(e){t(this.portalData.org,Object.assign({},e.portalData.org||{},n)),this.portalData.funds=e.portalData.funds||0,this.portalData.reputation=e.portalData.reputation||0,a(this.portalData.creditLines,e.portalData.creditLines||[]),a(this.portalData.members,e.portalData.members||[]),a(this.portalData.fleet,e.portalData.fleet||[]),a(this.portalData.assets,e.portalData.assets||[]),a(this.portalData.activity,e.portalData.activity||[]),a(this.portalData.roadmap,e.portalData.roadmap||[]),t(this.session,e.session||{})}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{createSignal:n}=window.RegistryApp.runtime,{portalData:r}=e.data;e.store=new class{constructor(){[this.getFunds,this.setFunds]=n(r.funds),[this.getReputation,this.setReputation]=n(r.reputation),[this.getMembers,this.setMembers]=n([...r.members]),[this.getCreditLines,this.setCreditLines]=n([...r.creditLines]),[this.getFleet,this.setFleet]=n([...r.fleet]),[this.getAssets,this.setAssets]=n([...r.assets]),[this.getActivity,this.setActivity]=n([...r.activity]),[this.getTreasuryNotice,this.setTreasuryNotice]=n({type:"",text:""}),[this.getModal,this.setModal]=n(null),[this.getOrgDisbanded,this.setOrgDisbanded]=n(!1)}hydrateFromPayload(e){const n=e.portalData||{};this.setFunds(n.funds||0),this.setReputation(n.reputation||0),this.setMembers([...n.members||[]]),this.setCreditLines([...n.creditLines||[]]),this.setFleet([...n.fleet||[]]),this.setAssets([...n.assets||[]]),this.setActivity([...n.activity||[]])}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n,session:r}=e.data;e.getters=new class{formatCurrency(e){return"$"+Number(e||0).toLocaleString()}formatVehicleType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatAssetType(e){return e?e.charAt(0).toUpperCase()+e.slice(1):""}formatDisplayName(e){return e?String(e).trim().split(/\s+/).map(e=>e?e.charAt(0).toUpperCase()+e.slice(1).toLowerCase():"").join(" "):""}getAssetReadiness(){const r=e.store?e.store.getFleet():n.fleet;if(0===r.length)return null;const t=r.reduce((e,n)=>e+(100-parseInt(n.damage,10)),0);return Math.round(t/r.length)}getNormalizedRole(){return String(r.role||"").trim().toUpperCase()}isDefaultOrg(){return!0===n.org.isDefault||"DEFAULT"===String(n.org.tag||"").trim().toUpperCase()}isOrgOwner(){const e=String(n.org.ownerUid||n.org.owner||"").trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return e&&t?t===e:String(r.actorName||"").trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isSessionCeo(){return!0===r.ceo}isOrgLeaderOrCeo(){return this.isOrgOwner()||"LEADER"===this.getNormalizedRole()||this.isDefaultOrg()&&this.isSessionCeo()}canManageMembers(){return this.isOrgLeaderOrCeo()}canManageTreasury(){return this.isOrgLeaderOrCeo()}canDisbandOrg(){return this.isOrgOwner()&&!this.isDefaultOrg()}canLeaveOrg(){return!this.isDefaultOrg()&&!this.isOrgOwner()}getMemberName(e){return String(e&&"object"==typeof e?e.name||"":e||"")}getMemberUid(e){return e&&"object"==typeof e?String(e.uid||""):""}isOwnerMember(e){return this.getMemberName(e).trim().toLowerCase()===String(n.org.owner||"").trim().toLowerCase()}isCurrentMember(e){const n=this.getMemberUid(e).trim().toLowerCase(),t=String(r.actorUid||"").trim().toLowerCase();return n&&t?n===t:this.getMemberName(e).trim().toLowerCase()===String(r.actorName||"").trim().toLowerCase()}isProtectedMember(e){return this.isOwnerMember(e)||this.isCurrentMember(e)}}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{portalData:n}=e.data,r=e.store,t=e.getters,a=window.RegistryApp.store;e.actions=new class{constructor(){this.treasuryNoticeTimer=null}showTreasuryNotice(e,n){r.setTreasuryNotice({type:e,text:n}),this.treasuryNoticeTimer&&clearTimeout(this.treasuryNoticeTimer),this.treasuryNoticeTimer=setTimeout(()=>{r.setTreasuryNotice({type:"",text:""}),this.treasuryNoticeTimer=null},3500)}parseAmount(e){const n=Number(e);return Number.isFinite(n)?Math.round(n):0}getInputValue(e){const n=document.getElementById(e);return n?n.value:""}closePortal(){const e=window.RegistryApp?window.RegistryApp.bridge:null;e&&"function"==typeof e.close?e.close({}):a&&a.setView("home")}openModal(e){"payroll"!==e&&"transfer"!==e&&"credit"!==e||t.canManageTreasury()?("disband"!==e||t.canDisbandOrg())&&("leave"!==e||t.canLeaveOrg())&&r.setModal({type:e}):this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions.")}closeModal(){r.setModal(null)}removeMember(e){if(!t.canManageMembers())return!1;if(t.isProtectedMember(e))return!1;const n=t.getMemberUid(e),a=t.getMemberName(e);return r.setMembers(e=>e.filter(e=>n?e.uid!==n:e.name!==a)),r.setCreditLines(e=>e.filter(e=>n?e.uid!==n:e.member!==a)),!0}disbandOrganization(){if(!t.canDisbandOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestDisbandOrg?(this.closeModal(),e.requestDisbandOrg(),!0):(this.showTreasuryNotice("error","Disband bridge is unavailable."),!1)}leaveOrganization(){if(!t.canLeaveOrg())return!1;const e=window.RegistryApp?window.RegistryApp.bridge:null;return e&&"function"==typeof e.requestLeaveOrg?(this.closeModal(),e.requestLeaveOrg(),!0):(this.showTreasuryNotice("error","Leave bridge is unavailable."),!1)}runPayroll(e){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const n=r.getMembers(),a=r.getFunds();if(0===n.length)return this.showTreasuryNotice("error","No members available for payroll."),!1;if(e<=0)return this.showTreasuryNotice("error","Enter a valid payroll amount."),!1;const o=e*n.length;return o>a?(this.showTreasuryNotice("error","Insufficient org funds for payroll."),!1):(r.setFunds(a-o),this.showTreasuryNotice("success",`Payroll sent to ${n.length} members for ${t.formatCurrency(o)}.`),!0)}sendFundsToMember(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;const a=r.getFunds();return e?n<=0?(this.showTreasuryNotice("error","Enter a valid transfer amount."),!1):n>a?(this.showTreasuryNotice("error","Insufficient org funds for this transfer."),!1):(r.setFunds(a-n),this.showTreasuryNotice("success",`${t.formatCurrency(n)} sent to ${e}.`),!0):(this.showTreasuryNotice("error","Select a member to receive funds."),!1)}grantCreditLine(e,n){if(!t.canManageTreasury())return this.showTreasuryNotice("error","Only the organization leader or CEO can manage treasury actions."),!1;if(!e)return this.showTreasuryNotice("error","Select a member for the credit line."),!1;if(n<=0)return this.showTreasuryNotice("error","Enter a valid credit line amount."),!1;const a=r.getMembers().find(n=>t.getMemberUid(n)===e),o=a?t.getMemberName(a):"";if(!o)return this.showTreasuryNotice("error","Selected member was not found in the organization roster."),!1;const i=window.RegistryApp?window.RegistryApp.bridge:null;return i&&"function"==typeof i.requestCreditLine?i.requestCreditLine({memberUid:e,memberName:o,amount:n}):(this.showTreasuryNotice("error","Credit line bridge is unavailable."),!1)}}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-navbar",o=`[${a}]`,i=`\n${o} {\n background: var(--bg-surface);\n border-bottom: 1px solid var(--border);\n box-shadow: var(--shadow);\n}\n\n${o} .app-navbar-inner {\n display: flex;\n justify-content: space-between;\n align-items: center;\n max-width: 1200px;\n width: 100%;\n margin: 0 auto;\n padding: 1rem 2rem;\n box-sizing: border-box;\n}\n\n${o} .app-navbar-brand {\n display: flex;\n flex-direction: column;\n gap: 0.125rem;\n}\n\n${o} .app-navbar-kicker {\n font-size: 0.7rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-navbar-title {\n font-size: 1.25rem;\n font-weight: 700;\n color: var(--primary-hover);\n letter-spacing: -0.025em;\n}\n\n${o} .app-navbar-actions {\n display: flex;\n align-items: center;\n gap: 1.5rem;\n}\n\n${o} .app-navbar-view {\n font-size: 0.8rem;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n color: var(--text-muted);\n font-weight: 600;\n}\n\n${o} .app-close-btn {\n background: transparent;\n color: var(--text-muted);\n border: 1px solid var(--border);\n padding: 0.5rem 1rem;\n font-size: 0.85rem;\n}\n\n${o} .app-close-btn:hover {\n background: var(--bg-surface-hover);\n color: var(--primary-hover);\n border-color: var(--primary);\n transform: none;\n box-shadow: none;\n}\n\n@media (max-width: 960px) {\n ${o} .app-navbar-inner {\n flex-direction: column;\n align-items: flex-start;\n padding: 1rem 1.5rem;\n }\n\n ${o} .app-navbar-actions {\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Navbar=function({kicker:e="ORBIS",title:n="",viewLabel:o="",actionLabel:s="",onAction:l=null}){return t("shared-navbar",i),r("nav",{className:"app-navbar",[a]:""},r("div",{className:"app-navbar-inner"},r("div",{className:"app-navbar-brand"},r("span",{className:"app-navbar-kicker"},e),r("span",{className:"app-navbar-title"},n)),r("div",{className:"app-navbar-actions"},r("span",{className:"app-navbar-view"},o),s&&"function"==typeof l?r("button",{type:"button",className:"app-close-btn",onClick:l},s):null)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Header=function({title:e,subtitle:n="Organization Registration & Management Portal",onTitleClick:t=null}){return r("div",{className:"header"},r("h1",{style:{cursor:t?"pointer":"default"},onClick:t},e),r("p",null,n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.OrgPortal=window.OrgPortal||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Hero=function({className:e="",kicker:n="",title:t="",subtitle:a="",meta:o=""}){const i=["card org-panel org-span-12 org-page-header",e].filter(Boolean).join(" ");return r("section",{className:i},r("div",{className:"org-page-heading"},r("span",{className:"org-page-kicker"},n),r("h1",{className:"org-page-title"},t),r("p",{className:"org-page-subtitle"},a),r("span",{className:"org-page-meta"},o)))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r}=n.runtime;e.componentFns=e.componentFns||{},e.componentFns.Footer=function({sections:e=[]}){return r("div",{className:"footer"},r("div",{className:"wrapper"},...e.map(e=>r("div",null,r("h3",null,e.title),r("ul",{style:{listStyleType:"none",padding:0}},...(e.items||[]).map(e=>r("li",null,e)))))))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-modal",o=`[${a}]`,i=`\n${o} {\n position: fixed;\n inset: 0;\n background: rgb(15 23 42 / 0.38);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 1.5rem;\n z-index: 20;\n}\n\n${o} .app-modal-card {\n width: min(100%, 30rem);\n margin-bottom: 0;\n text-align: left;\n}\n\n${o} .app-modal-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1rem;\n}\n\n${o} .app-modal-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .app-modal-close {\n width: 2.25rem;\n height: 2.25rem;\n padding: 0;\n background: var(--bg-surface);\n color: var(--text-main);\n border: 1px solid var(--border);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-close:hover {\n background: var(--bg-surface-hover);\n color: var(--text-main);\n box-shadow: none;\n transform: none;\n}\n\n${o} .app-modal-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n\n${o} .app-modal-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${o} .app-modal-form input,\n${o} .app-modal-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s, box-shadow 0.2s;\n}\n\n${o} .app-modal-form input:focus,\n${o} .app-modal-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(71 85 105 / 0.12);\n}\n\n${o} .app-modal-form input:disabled,\n${o} .app-modal-form select:disabled {\n background: #f1f5f9;\n color: var(--text-muted);\n cursor: not-allowed;\n}\n\n${o} .app-modal-actions {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.75rem;\n margin-top: 0.5rem;\n}\n\n${o} .app-modal-actions button + button,\n${o} .app-modal-danger-actions button + button {\n margin-left: 0;\n}\n\n${o} .app-modal-danger {\n display: flex;\n flex-direction: column;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid #fecaca;\n border-radius: var(--radius);\n background: #fff1f2;\n align-items: flex-start;\n}\n\n${o} .app-modal-danger p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .app-modal-danger-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.75rem;\n}\n\n@media (max-width: 960px) {\n ${o} .app-modal-head,\n ${o} .app-modal-danger {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Modal=function({title:e="",body:n=null,onClose:o=null}){return t("shared-modal",i),r("div",{className:"app-modal-backdrop",[a]:"",onClick:e=>{e.target===e.currentTarget&&o&&o()}},r("div",{className:"card app-modal-card"},r("div",{className:"app-modal-head"},r("div",null,r("h2",{className:"app-modal-title"},e)),r("button",{type:"button",className:"app-modal-close",onClick:o,"aria-label":"Close dialog"},"x")),n))}}(),function(){const e=window.SharedUI=window.SharedUI||{},n=window.RegistryApp=window.RegistryApp||{},{h:r,ensureScopedStyle:t}=n.runtime,a="data-ui-panel-card",o=`[${a}]`,i=`\n${o} {\n display: flex;\n flex-direction: column;\n height: 100%;\n min-height: 0;\n}\n\n${o} .org-panel-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${o} .org-panel-body {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n min-height: 0;\n}\n\n${o} .org-eyebrow {\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.12em;\n text-transform: uppercase;\n color: var(--text-muted);\n margin-bottom: 0.4rem;\n}\n\n${o} .org-panel-title {\n margin: 0;\n color: var(--primary-hover);\n font-size: 1.45rem;\n}\n\n${o} .org-panel-subtitle {\n margin: 0.35rem 0 0;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n@media (max-width: 960px) {\n ${o} .org-panel-head {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.PanelCard=function({className:e="",eyebrow:n="",title:o="",subtitle:s="",headerExtras:l=null,body:d=null,rootProps:c={}}){const m=["card org-panel",e].filter(Boolean).join(" ");return t("shared-panel-card",i),r("section",{className:m,[a]:"",...c},r("div",{className:"org-panel-head"},r("div",null,n?r("div",{className:"org-eyebrow"},n):null,r("h2",{className:"org-panel-title"},o),s?r("p",{className:"org-panel-subtitle"},s):null),l),r("div",{className:"org-panel-body"},d))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-metric-card",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.45rem;\n padding: 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n}\n\n${a}:nth-child(4n + 2),\n${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(226 232 240) 100%);\n border-color: rgb(100 116 139 / 0.35);\n box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6);\n}\n\n${a} .org-metric-label {\n font-size: 0.76rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n color: var(--text-muted);\n}\n\n${a} .org-metric-value {\n font-size: 1.8rem;\n color: var(--primary-hover);\n line-height: 1.1;\n}\n\n${a}:nth-child(4n + 2) .org-metric-value,\n${a}:nth-child(4n + 3) .org-metric-value {\n color: #334155;\n}\n\n${a} .org-metric-note {\n color: var(--text-muted);\n font-size: 0.9rem;\n}\n\n@media (max-width: 960px) {\n ${a}:nth-child(4n + 3) {\n background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);\n border-color: var(--border);\n box-shadow: none;\n }\n\n ${a}:nth-child(4n + 3) .org-metric-value {\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MetricCard=function(e,a,i){return r("portal-metric-card",o),n("div",{className:"org-metric-card",[t]:""},n("span",{className:"org-metric-label"},e),n("strong",{className:"org-metric-value"},a),n("span",{className:"org-metric-note"},i))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-simple-stat",a=`[${t}]`,o=`\n${a} {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n min-width: 90px;\n}\n\n${a} .org-simple-label {\n font-size: 0.72rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${a} .org-simple-value {\n font-size: 0.95rem;\n color: var(--text-main);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.SimpleStat=function(e,a){return r("portal-simple-stat",o),n("div",{className:"org-simple-stat",[t]:""},n("span",{className:"org-simple-label"},e),n("strong",{className:"org-simple-value"},a))}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.store,o=e.getters,i="data-ui-overview-card",s=`[${i}]`,l=`\n${s} .org-hero-grid {\n display: grid;\n grid-template-columns: 1.3fr 1fr;\n gap: 1.5rem;\n align-items: start;\n}\n\n${s} .org-summary {\n margin: 0;\n font-size: 1.05rem;\n color: var(--text-main);\n}\n\n${s} .org-meta-row {\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 1rem;\n margin-top: 1.5rem;\n}\n\n${s} .org-meta-item {\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-meta-item:nth-child(even) {\n background: linear-gradient(180deg, rgb(241 245 249) 0%, rgb(226 232 240) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${s} .org-meta-value {\n font-size: 1rem;\n font-weight: 600;\n color: var(--primary-hover);\n}\n\n${s} .org-metric-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${s} .org-hero-grid,\n ${s} .org-meta-row,\n ${s} .org-metric-grid {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.OverviewCard=function(){const s=e.componentFns.MetricCard,d=window.SharedUI.componentFns.PanelCard,c=o.getAssetReadiness(),m=t.org.headquarters||"ArmA Verse",g=a.getAssets().length,p=a.getFleet().length,u=a.getFunds(),f=a.getMembers().length,b=a.getReputation();return r("portal-overview-card",l),d({className:"org-span-12",eyebrow:t.org.tag,title:"Organization Overview",rootProps:{[i]:""},body:n("div",{className:"org-hero-grid"},n("div",{className:"org-hero-copy"},n("p",{className:"org-summary"},t.org.type," operating from ",m,". Treasury, fleet status, inventory, and roster management are surfaced here first."),n("div",{className:"org-meta-row"},n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Director"),n("span",{className:"org-meta-value"},o.formatDisplayName(t.org.owner))),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Active Members"),n("span",{className:"org-meta-value"},`${f} total`)),n("div",{className:"org-meta-item"},n("span",{className:"org-meta-label"},"Fleet Readiness"),n("span",{className:"org-meta-value"},null===c?"N/A":`${c}%`)))),n("div",{className:"org-metric-grid"},s("Org Funds",o.formatCurrency(u),"Organization treasury balance"),s("Reputation",b,"Organization standing"),s("Asset Lines",g,"Tracked supply and equipment entries"),s("Fleet Vehicles",p,"Tracked air, ground, and naval vehicles")))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-fleet-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FleetCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getFleet();return r("portal-fleet-card",s),t({className:"org-scroll-panel org-span-7",title:"Fleet",subtitle:"Individual vehicles with type, status, and overall damage.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatVehicleType(e.type)),i("Status",e.status),i("Damage",e.damage)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r,createSignal:t}=e.runtime,{portalData:a}=e.data,o=e.store,i=e.getters,s=e.actions,l="data-ui-treasury-card",d=`[${l}]`,[c,m]=t("overview"),[g,p]=t(!1),u=`\n${d} .org-treasury-menu {\n position: relative;\n}\n\n${d} .org-menu-btn {\n width: 2.75rem;\n height: 2.75rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0;\n border: 1px solid var(--border);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n${d} .org-menu-btn:hover {\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.65);\n}\n\n${d} .org-menu-btn svg {\n width: 1.1rem;\n height: 1.1rem;\n}\n\n${d} .org-menu-dropdown {\n position: absolute;\n top: calc(100% + 0.6rem);\n right: 0;\n min-width: 10.5rem;\n padding: 0.45rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.12);\n display: flex;\n flex-direction: column;\n gap: 0.35rem;\n z-index: 5;\n}\n\n${d} .org-menu-option + .org-menu-option {\n margin-left: 0;\n}\n\n${d} .org-menu-option {\n width: 100%;\n justify-content: flex-start;\n background: transparent;\n color: var(--text-main);\n border: 1px solid transparent;\n}\n\n${d} .org-menu-option:hover {\n background: #f8fafc;\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-menu-option.is-active {\n background: rgb(226 232 240 / 0.7);\n color: var(--primary-hover);\n border-color: rgb(148 163 184 / 0.35);\n}\n\n${d} .org-finance-meta {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n margin-bottom: 1.5rem;\n}\n\n${d} .org-finance-meta > div {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n display: flex;\n flex-direction: column;\n gap: 0.4rem;\n}\n\n${d} .org-meta-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-action-grid {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n margin-bottom: 1rem;\n}\n\n${d} .org-action-grid button + button {\n margin-left: 0;\n}\n\n${d} .org-action-grid button {\n width: 100%;\n}\n\n${d} .org-access-note {\n margin: 0 0 1rem;\n color: var(--text-muted);\n font-size: 0.95rem;\n}\n\n${d} .org-credit-summary {\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n padding: 0.85rem 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-summary strong {\n font-size: 1rem;\n}\n\n${d} .org-credit-summary span:last-child {\n font-size: 0.92rem;\n line-height: 1.45;\n}\n\n${d} .org-credit-lines-list {\n display: flex;\n flex-direction: column;\n gap: 0.85rem;\n}\n\n${d} .org-treasury-body {\n display: flex;\n flex: 1;\n flex-direction: column;\n gap: 1rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${d} .org-credit-line-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${d} .org-credit-line-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${d} .org-credit-line-member {\n display: flex;\n flex-direction: column;\n gap: 0.3rem;\n}\n\n${d} .org-credit-line-label {\n font-size: 0.76rem;\n text-transform: uppercase;\n letter-spacing: 0.08em;\n color: var(--text-muted);\n}\n\n${d} .org-credit-line-empty {\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n color: var(--text-muted);\n}\n\n@media (max-width: 960px) {\n ${d} .org-finance-meta {\n grid-template-columns: 1fr;\n }\n\n ${d} .org-credit-line-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.TreasuryCard=function(){const e=window.SharedUI.componentFns.PanelCard,t=o.getCreditLines(),a=o.getReputation(),d=i.canManageTreasury(),f=c(),b=g(),w=1===t.length?"1 active credit line":`${t.length} active credit lines`;return r("portal-treasury-card",u),e({className:"org-scroll-panel org-span-5",title:"Treasury",subtitle:"Organization funds, reputation and payouts.",headerExtras:n("div",{className:"org-treasury-menu"},n("button",{type:"button",className:"org-menu-btn",title:"Treasury views","aria-label":"Treasury views",onClick:()=>p(e=>!e)},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("line",{x1:"4",y1:"7",x2:"20",y2:"7"}),n("line",{x1:"4",y1:"12",x2:"20",y2:"12"}),n("line",{x1:"4",y1:"17",x2:"20",y2:"17"}))),b?n("div",{className:"org-menu-dropdown"},n("button",{type:"button",className:"overview"===f?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("overview"),p(!1)}},"Overview"),n("button",{type:"button",className:"credit"===f?"org-menu-option is-active":"org-menu-option",onClick:()=>{m("credit"),p(!1)}},"Credit Lines")):null),rootProps:{[l]:""},body:n("div",{className:"org-treasury-body"},"credit"===f?t.length>0?n("div",{className:"org-credit-lines-list"},...t.map(e=>n("article",{className:"org-credit-line-row"},n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Member"),n("strong",null,e.member)),n("div",{className:"org-credit-line-member"},n("span",{className:"org-credit-line-label"},"Amount"),n("strong",null,i.formatCurrency(e.amount)))))):n("div",{className:"org-credit-line-empty"},"No active credit lines."):n("div",null,n("div",{className:"org-finance-meta"},n("div",null,n("span",{className:"org-meta-label"},"Funds"),n("strong",null,i.formatCurrency(o.getFunds()))),n("div",null,n("span",{className:"org-meta-label"},"Reputation"),n("strong",null,`${a}`))),d?n("div",{className:"org-action-grid"},n("button",{type:"button",onClick:()=>s.openModal("payroll")},"Run Payroll"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("transfer")},"Send Funds"),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>s.openModal("credit")},"Credit Line")):n("p",{className:"org-access-note"},"Only the organization leader or CEO can manage treasury actions."),n("div",{className:"org-credit-summary"},n("span",{className:"org-meta-label"},"Credit Line Status"),n("strong",null,w),n("span",null,t.length>0?"Open the Credit Lines tab to review assigned members and amounts.":"Assign a credit line to create the first approved member limit."))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a=e.getters,o="data-ui-assets-card",i=`[${o}]`,s=`\n${i} .org-simple-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${i} .org-simple-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${i} .org-simple-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${i} .org-simple-name {\n color: var(--primary-hover);\n}\n\n${i} .org-simple-meta {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 1rem;\n}\n\n@media (max-width: 960px) {\n ${i} .org-simple-row {\n flex-direction: column;\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.AssetsCard=function(){const t=window.SharedUI.componentFns.PanelCard,i=e.componentFns.SimpleStat,l=e.store.getAssets();return r("portal-assets-card",s),t({className:"org-scroll-panel org-span-7",title:"Assets",subtitle:"Inventory supplies and equipment with quantity totals.",rootProps:{[o]:""},body:n("div",{className:"org-simple-list"},...l.map(e=>n("article",{className:"org-simple-row"},n("strong",{className:"org-simple-name"},e.name),n("div",{className:"org-simple-meta"},i("Type",a.formatAssetType(e.type)),i("Quantity",e.quantity)))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.getters,o=e.actions,i="data-ui-members-card",s=`[${i}]`,l=`\n${s} .org-name-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${s} .org-name-row {\n display: flex;\n align-items: center;\n justify-content: flex-start;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${s} .org-name-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n}\n\n${s} .org-name-row button {\n margin-left: auto;\n}\n\n@media (max-width: 960px) {\n ${s} .org-name-row {\n flex-direction: column;\n align-items: flex-start;\n }\n\n ${s} .org-name-row button {\n margin-left: 0;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.MembersCard=function(){const e=window.SharedUI.componentFns.PanelCard,s=t.getMembers(),d=a.canManageMembers();return r("portal-members-card",l),e({className:"org-scroll-panel org-span-5",title:"Members",subtitle:"Current roster listing. The organization owner and your own member entry cannot be removed.",rootProps:{[i]:""},body:n("div",{className:"org-name-list"},...s.map(e=>{const r=d&&!a.isProtectedMember(e);return n("article",{className:"org-name-row"},n("strong",null,e.name),r?n("button",{type:"button",className:"org-danger-btn org-icon-btn",title:`Remove ${e.name}`,"aria-label":`Remove ${e.name}`,onClick:()=>o.removeMember(e)},n("svg",{className:"org-icon",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round","aria-hidden":"true"},n("path",{d:"M9 3h6"}),n("path",{d:"M4 7h16"}),n("path",{d:"M6 7l1 13h10l1-13"}),n("path",{d:"M10 11v6"}),n("path",{d:"M14 11v6"}))):null)}))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t}=e.data,a="data-ui-activity-card",o=`[${a}]`,i=`\n${o} .org-activity-list {\n display: flex;\n flex-direction: column;\n flex: 1;\n gap: 0.85rem;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-activity-row {\n padding: 1rem;\n border: 1px solid var(--border);\n border-left: 3px solid #94a3b8;\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-activity-row:nth-child(even) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(148 163 184 / 0.45);\n border-left-color: #64748b;\n}\n\n${o} .org-activity-row p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-activity-time {\n display: inline-block;\n margin-bottom: 0.35rem;\n color: var(--text-muted);\n font-size: 0.8rem;\n font-weight: 700;\n letter-spacing: 0.05em;\n text-transform: uppercase;\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.ActivityCard=function(){const t=window.SharedUI.componentFns.PanelCard,o=e.store.getActivity();return r("portal-activity-card",i),t({className:"org-scroll-panel org-span-6",title:"Command Feed",subtitle:"Recent organization-level actions and updates.",rootProps:{[a]:""},body:n("div",{className:"org-activity-list"},...o.map(e=>n("article",{className:"org-activity-row"},n("span",{className:"org-activity-time"},e.time),n("p",null,e.text))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t="data-ui-future-card",a=[{name:"Contracts Board",status:"Planned",detail:"Track payouts, assignments, and claim approvals."},{name:"Diplomacy",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Logistics Queue",status:"Future Review",detail:"Possible future module pending a full design and scope review."},{name:"Permissions",status:"Future Review",detail:"Possible future module pending a full design and scope review."}],o=`[${t}]`,i=`\n${o} .org-roadmap-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 1rem;\n flex: 1;\n min-height: 0;\n overflow: auto;\n padding-right: 0.35rem;\n scrollbar-width: thin;\n scrollbar-color: #94a3b8 #e2e8f0;\n}\n\n${o} .org-roadmap-card {\n padding: 1rem;\n display: flex;\n flex-direction: column;\n gap: 0.7rem;\n border: 1px solid var(--border);\n border-radius: var(--radius);\n background: #f8fafc;\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2),\n${o} .org-roadmap-card:nth-child(4n + 3) {\n background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%);\n border-color: rgb(100 116 139 / 0.4);\n}\n\n${o} .org-roadmap-card p {\n margin: 0;\n color: var(--text-main);\n}\n\n${o} .org-list-tag {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0.2rem 0.55rem;\n border-radius: 999px;\n font-size: 0.72rem;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n background: #e2e8f0;\n color: var(--primary-hover);\n}\n\n${o} .org-roadmap-card:nth-child(4n + 2) .org-list-tag,\n${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #cbd5e1;\n color: #1e293b;\n}\n\n@media (max-width: 960px) {\n ${o} .org-roadmap-grid {\n grid-template-columns: 1fr;\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) {\n background: #f8fafc;\n border-color: var(--border);\n }\n\n ${o} .org-roadmap-card:nth-child(4n + 3) .org-list-tag {\n background: #e2e8f0;\n color: var(--primary-hover);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.FutureCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-future-card",i),e({className:"org-scroll-panel org-span-6",title:"Expansion Slots",subtitle:"Potential modules are tagged by status such as Planned, In Design, In Review, and Future Review.",rootProps:{[t]:""},body:n("div",{className:"org-roadmap-grid"},...a.map(e=>n("article",{className:"org-roadmap-card"},n("span",{className:"org-list-tag"},e.status),n("strong",null,e.name),n("p",null,e.detail))))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.getters,a=e.actions,o="data-ui-danger-card",i=`[${o}]`,s=`\n${i} {\n border-color: #fecaca;\n background: linear-gradient(180deg, #ffffff 0%, #fff7f7 100%);\n}\n\n${i} .org-danger-copy {\n margin-bottom: 1rem;\n}\n\n${i} .org-danger-copy strong,\n${i} .org-danger-copy p {\n display: block;\n}\n\n${i} .org-danger-copy p {\n margin: 0.4rem 0 0;\n color: var(--text-muted);\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.DangerCard=function(){const e=window.SharedUI.componentFns.PanelCard;return r("portal-danger-card",s),t.canDisbandOrg()?e({className:"org-span-12 org-danger-panel",title:"Organization Controls",subtitle:"Leader-only actions for membership and permanent organization removal.",rootProps:{[o]:""},body:n("div",null,n("div",{className:"org-danger-copy"},n("strong",null,"Disband organization"),n("p",null,"This removes the organization and revokes access to the portal for all members.")),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.openModal("disband")},"Disband Organization"))}):null}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=e.store,a=e.actions;e.componentFns=e.componentFns||{},e.componentFns.ModalLayer=function(){const e=window.SharedUI.componentFns.Modal,o=t.getModal();if(!o)return null;const i=t.getMembers(),s=0===i.length?{disabled:!0}:{};let l="",d=null;return"payroll"===o.type?(l="Run Payroll",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Amount Per Member"),n("input",{id:"treasury-payroll-amount",type:"number",min:"1",placeholder:"500",autofocus:"true"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",onClick:()=>{a.runPayroll(a.parseAmount(a.getInputValue("treasury-payroll-amount")))&&a.closeModal()}},"Run Payroll")))):"transfer"===o.type?(l="Send Funds",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-transfer-member",...s},...i.map(e=>n("option",{value:e.name},e.name)))),n("div",null,n("label",null,"Amount"),n("input",{id:"treasury-transfer-amount",type:"number",min:"1",placeholder:"1500"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...s,onClick:()=>{a.sendFundsToMember(String(a.getInputValue("treasury-transfer-member")||""),a.parseAmount(a.getInputValue("treasury-transfer-amount")))&&a.closeModal()}},"Send Funds")))):"credit"===o.type?(l="Assign Credit Line",d=n("div",{className:"app-modal-form"},n("div",null,n("label",null,"Member"),n("select",{id:"treasury-credit-member",...s},...i.map(e=>n("option",{value:e.uid},e.name)))),n("div",null,n("label",null,"Credit Amount"),n("input",{id:"treasury-credit-amount",type:"number",min:"1",placeholder:"5000"})),n("div",{className:"app-modal-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",...s,onClick:()=>{a.grantCreditLine(String(a.getInputValue("treasury-credit-member")||""),a.parseAmount(a.getInputValue("treasury-credit-amount")))&&a.closeModal()}},"Assign Credit Line")))):"disband"===o.type?(l="Disband Organization",d=n("div",{className:"app-modal-danger"},n("p",null,"This action is permanent. Disband ",r.org.name,"?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.disbandOrganization()},"Confirm Disband")))):"leave"===o.type&&(l="Leave Organization",d=n("div",{className:"app-modal-danger"},n("p",null,"Leave ",r.org.name," and return to the default organization?"),n("div",{className:"app-modal-danger-actions"},n("button",{type:"button",className:"org-secondary-btn",onClick:()=>a.closeModal()},"Cancel"),n("button",{type:"button",className:"org-danger-btn",onClick:()=>a.leaveOrganization()},"Confirm Leave")))),e({title:l,body:d,onClose:()=>a.closeModal()})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n}=e.runtime,{portalData:r}=e.data,t=window.RegistryApp.store;e.componentFns=e.componentFns||{},e.componentFns.DisbandedView=function(){return(0,window.SharedUI.componentFns.PanelCard)({className:"org-span-12 org-empty-state",eyebrow:"Organization Removed",title:r.org.name,body:n("div",null,n("p",{className:"org-summary"},"This organization has been disbanded. Member access, assets, and fleet management are no longer available from this portal preview."),n("button",{type:"button",className:"org-secondary-btn",onClick:()=>t.setView("home")},"Return to Registry"))})}}(),function(){const e=window.OrgPortal=window.OrgPortal||{},{h:n,ensureScopedStyle:r}=e.runtime,{portalData:t,session:a}=e.data,o=e.store,i="[data-ui-portal-view]";r("portal-view",`\n ${i} {\n --org-row-card-max-height: 36rem;\n }\n\n ${i} .org-toast-stack {\n position: fixed;\n top: 1.5rem;\n right: 2rem;\n z-index: 20;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n pointer-events: none;\n }\n\n ${i} .org-toast {\n max-width: 24rem;\n padding: 0.9rem 1rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: #fff;\n box-shadow: 0 12px 28px rgb(15 23 42 / 0.14);\n font-size: 0.92rem;\n pointer-events: auto;\n }\n\n ${i} .org-toast.is-success {\n background: #ecfdf5;\n border-color: #bbf7d0;\n color: #166534;\n }\n\n ${i} .org-toast.is-error {\n background: #fef2f2;\n border-color: #fecaca;\n color: #991b1b;\n }\n\n ${i} .org-dashboard-grid {\n display: grid;\n grid-template-columns: repeat(12, minmax(0, 1fr));\n gap: 1.5rem;\n align-items: stretch;\n }\n\n ${i} .org-panel {\n margin-bottom: 0;\n text-align: left;\n }\n\n ${i} .org-scroll-panel {\n display: flex;\n flex-direction: column;\n min-height: 0;\n max-height: var(--org-row-card-max-height);\n overflow: hidden;\n }\n\n ${i} .org-island-root {\n display: flex;\n align-self: stretch;\n min-height: 0;\n min-width: 0;\n }\n\n ${i} .org-island-root > .org-panel {\n height: 100%;\n width: 100%;\n }\n\n ${i} .org-span-12 {\n grid-column: span 12;\n }\n\n ${i} .org-span-7 {\n grid-column: span 7;\n }\n\n ${i} .org-span-6 {\n grid-column: span 6;\n }\n\n ${i} .org-span-5 {\n grid-column: span 5;\n }\n\n @media (max-width: 960px) {\n ${i} .org-toast-stack {\n top: 1rem;\n right: 1rem;\n left: 1rem;\n }\n\n ${i} .org-toast {\n max-width: none;\n }\n\n ${i} .org-span-12,\n ${i} .org-span-7,\n ${i} .org-span-6,\n ${i} .org-span-5 {\n grid-column: span 12;\n }\n\n ${i} .org-scroll-panel {\n max-height: none;\n }\n\n }\n `),e.components=e.components||{},e.componentFns=e.componentFns||{},e.componentFns.TreasuryNoticeLayer=function(){const e=o.getTreasuryNotice();return e.text?n("div",{className:"org-toast-stack"},n("div",{className:"error"===e.type?"org-toast is-error":"org-toast is-success"},e.text)):null},e.components.App=function(){const r=window.SharedUI.componentFns.Hero,i=window.SharedUI.componentFns.Footer,s=e.componentFns.FutureCard,l=e.componentFns.DangerCard,d=e.componentFns.DisbandedView,c=[{title:"Organization Controls",items:["Roster Management","Fleet Assignment","Treasury Permissions","Asset Registry"]},{title:"Planned Extensions",items:["Contracts Board","Diplomacy Layer","Procurement Queue","Reputation History"]}];return o.getOrgDisbanded()?n("main",{"data-ui-portal-view":""},n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),d())),n("div",{id:"org-portal-modal-root"}),i({sections:c})):n("main",{"data-ui-portal-view":""},n("div",{id:"org-portal-toast-root"}),n("div",{className:"container"},n("div",{className:"org-dashboard-grid"},r({kicker:t.org.tag,title:t.org.name,subtitle:"Player organization command portal",meta:`${a.actorName} - ${a.role}`}),n("div",{className:"org-island-root org-span-12",id:"org-overview-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-fleet-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-treasury-card-root"}),n("div",{className:"org-island-root org-span-5",id:"org-members-card-root"}),n("div",{className:"org-island-root org-span-7",id:"org-assets-card-root"}),n("div",{className:"org-island-root org-span-6",id:"org-activity-card-root"}),s(),l())),n("div",{id:"org-portal-modal-root"}),i({sections:c}))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-registration-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n align-items: center;\n width: 100%;\n}\n\n${i} .info-panel {\n text-align: left;\n padding: 1rem;\n}\n\n${i} .create-feature-list {\n text-align: left;\n margin-top: 1.5rem;\n list-style-type: none;\n padding: 0;\n}\n\n${i} .create-feature-item {\n margin-bottom: 0.5rem;\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n${i} .create-feature-icon {\n width: 1.2rem;\n height: 1.2rem;\n flex-shrink: 0;\n}\n\n${i} .price-tag {\n margin-top: 2rem;\n padding: 1rem;\n background: var(--bg-app);\n border-radius: var(--radius);\n border: 1px solid var(--border);\n}\n\n${i} .price-label {\n display: block;\n font-size: 0.9rem;\n color: var(--text-muted);\n}\n\n${i} .price-value {\n display: block;\n font-size: 2rem;\n font-weight: 700;\n color: var(--primary);\n}\n\n${i} .form-panel {\n margin: 0;\n}\n\n${i} .app-form {\n display: flex;\n flex-direction: column;\n gap: 1rem;\n text-align: left;\n}\n\n${i} .app-form label {\n display: block;\n margin-bottom: 0.5rem;\n color: var(--text-muted);\n font-weight: 500;\n font-size: 0.9rem;\n}\n\n${i} .app-form input,\n${i} .app-form select {\n width: 100%;\n padding: 0.75rem;\n border-radius: var(--radius);\n border: 1px solid var(--border);\n background: var(--bg-app);\n color: var(--text-main);\n font-family: inherit;\n font-size: 1rem;\n box-sizing: border-box;\n transition: border-color 0.2s;\n}\n\n${i} .app-form input:focus,\n${i} .app-form select:focus {\n outline: none;\n border-color: var(--primary);\n box-shadow: 0 0 0 2px rgb(59 130 246 / 0.1);\n}\n\n${i} .form-actions {\n margin-top: 1rem;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n align-items: center;\n}\n\n${i} .submit-btn {\n width: 100%;\n}\n\n${i} .cancel-link {\n font-size: 0.9rem;\n color: var(--text-muted);\n cursor: pointer;\n text-decoration: underline;\n}\n\n${i} .cancel-link:hover {\n color: var(--primary);\n}\n\n${i} .form-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n}\n\n${i} .form-feedback.is-error {\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.RegistrationView=function(){const e=t.getIsCreating(),i=t.getCreateError();r("main-registration-view",s);return n("div",{className:"split-container",[o]:""},n("div",{className:"info-panel"},n("h2",null,"Registration Details"),n("p",null,"Complete the form to add your organization to the Global Organization Registry."),n("ul",{className:"create-feature-list"},n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Official Organization Designator"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Secure Comms Channel"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"Deployment Roster Access"),n("li",{className:"create-feature-item"},n("svg",{viewBox:"0 0 24 24",fill:"none",stroke:"#10b981","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round",className:"create-feature-icon"},n("path",{d:"M20 6L9 17l-5-5"})),"After-Action Report Tools")),n("div",{className:"price-tag"},n("span",{className:"price-label"},"Registration Fee"),n("span",{className:"price-value"},"$50,000"))),n("div",{className:"form-panel card"},n("h2",null,"Organization Registration"),n("div",{className:"app-form"},n("div",null,n("label",null,"Organization Name"),n("input",{id:"org-create-name",type:"text",placeholder:"e.g. Task Force 141"})),n("div",null,n("label",null,"Organization Type"),n("select",{id:"org-create-type"},n("option",{value:"infantry"},"Infantry / Milsim"),n("option",{value:"aviation"},"Aviation Wing"),n("option",{value:"pmc"},"Private Military Company"),n("option",{value:"support"},"Logistics & Support"))),n("div",{className:"form-actions"},i?n("div",{className:"form-feedback is-error"},i):null,n("button",{type:"button",className:"submit-btn",disabled:e,onClick:()=>{const e={orgName:String(document.getElementById("org-create-name")?.value||"").trim(),type:String(document.getElementById("org-create-type")?.value||"")};a&&"function"==typeof a.requestCreateOrg?a.requestCreateOrg(e):t.failCreate("Registration bridge is not available.")}},e?"Submitting Registration...":"Submit Registration"),n("span",{className:"cancel-link",onClick:()=>t.setView("home")},"Cancel / Return to Main")))))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n,ensureScopedStyle:r}=e.runtime,t=e.store,a=e.bridge,o="data-ui-home-view",i=`[${o}]`,s=`\n${i} {\n display: grid;\n grid-template-columns: 1fr 1fr;\n gap: 2rem;\n margin-bottom: 2rem;\n}\n\n${i} .home-feedback {\n padding: 0.85rem 1rem;\n border-radius: var(--radius);\n font-size: 0.92rem;\n background: #fef2f2;\n border: 1px solid #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 960px) {\n ${i} {\n grid-template-columns: 1fr;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.HomeView=function(){const e=t.getIsAuthenticating(),i=t.getLoginError();return r("main-home-view",s),n("div",{className:"content",[o]:""},n("div",{className:"card"},n("h2",null,"Create Organization"),n("p",null,"Establish your Task Force, PMC, or Milsim unit with the Global Organization Network. Receive your official unit designator and TO&E authorization instantly."),n("button",{onClick:()=>t.setView("create")},"Register")),n("div",{className:"card"},n("h2",null,"Organization Portal"),n("p",null,"Access your unit dashboard to modify rosters, adjust active deployments, and submit after-action reports through the secure field uplink."),i?n("div",{className:"home-feedback"},i):null,n("button",{disabled:e,onClick:()=>{a?a.requestLogin({}):t.failLogin("Login bridge is not available.")}},e?"Opening Portal...":"Login")))}}(),function(){const e=window.RegistryApp=window.RegistryApp||{},{h:n}=e.runtime,r=e.store;e.components=e.components||{},e.components.App=function(){const t=window.SharedUI.componentFns.Navbar,a=window.SharedUI.componentFns.Header,o=window.SharedUI.componentFns.Footer,i=window.SharedUI.componentFns.WindowTitleBar,s=e.componentFns.HomeView,l=e.componentFns.RegistrationView,d=window.OrgPortal&&window.OrgPortal.components?window.OrgPortal.components.App:null,c=r.getView(),m=window.OrgPortal&&window.OrgPortal.getters?window.OrgPortal.getters:null,g=window.OrgPortal&&window.OrgPortal.actions?window.OrgPortal.actions:null,p="create"===c?"Organization Registration":"portal"===c?"Organization Portal":"Entry Hub";function u(){e.bridge&&"function"==typeof e.bridge.close?e.bridge.close({}):r.setView("home")}if("portal"===c&&d){const e=m&&"function"==typeof m.canLeaveOrg&&m.canLeaveOrg();return n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),t({title:"Global Organization Network",viewLabel:p,actionLabel:e?"Leave Organization":"",onAction:e&&g&&"function"==typeof g.openModal?()=>g.openModal("leave"):null}),n("div",{id:"org-portal-frame-root"}))}let f;return"home"===c?f=s():"create"===c&&(f=l()),n("div",{className:"app-shell"},i({kicker:"FORGE ORBIS",title:"Global Organization Network",onClose:u,closeLabel:"Close organization interface"}),n("main",null,t({title:"Global Organization Network",viewLabel:p}),n("div",{className:"container"},a({title:"Global Organization Network",onTitleClick:()=>r.setView("home")}),f),o({sections:[{title:"Registry Resources",items:["Registration Guidelines","Tax & Fee Schedule","Legal Compliance","Trademark Database"]},{title:"Bureau Support",items:["Office: Sector 7 Admin Block","Hours: 0800 - 1600 (GST)","Helpdesk: 555-01-REGISTRY","support@org-bureau.gov"]}]})))}}(),function(){const e=window.ForgeWebUI,n=window.RegistryApp,r=window.OrgPortal,t=[{id:"org-portal-frame-root",preserveScroll:!0,render:()=>r.components.App()},{id:"org-portal-toast-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryNoticeLayer()},{id:"org-overview-card-root",preserveScroll:!1,render:()=>r.componentFns.OverviewCard()},{id:"org-fleet-card-root",preserveScroll:!0,render:()=>r.componentFns.FleetCard()},{id:"org-treasury-card-root",preserveScroll:!1,render:()=>r.componentFns.TreasuryCard()},{id:"org-members-card-root",preserveScroll:!0,render:()=>r.componentFns.MembersCard()},{id:"org-assets-card-root",preserveScroll:!0,render:()=>r.componentFns.AssetsCard()},{id:"org-activity-card-root",preserveScroll:!0,render:()=>r.componentFns.ActivityCard()},{id:"org-portal-modal-root",preserveScroll:!1,render:()=>r.componentFns.ModalLayer()}];e.createApp({name:"org",root:"#app",setup({root:r}){const a=function(){const n=new Map;return{sync:function(){t.forEach(r=>{const t=document.getElementById(r.id),a=n.get(r.id);if(!t)return void(a&&(a.handle.dispose(),n.delete(r.id)));if(a&&a.container===t)return;a&&a.handle.dispose();const o=e.mount(t,r.render,{preserveScroll:r.preserveScroll});n.set(r.id,{container:t,handle:o})})}}}();e.mount(r,()=>n.components.App(),{preserveScroll:!1}),n.bridge.ready({loaded:!0}),e.effect(()=>{n.store.getView(),requestAnimationFrame(()=>{a.sync()})})}}).start()}(); \ No newline at end of file diff --git a/arma/client/addons/org/ui/_site/script.js b/arma/client/addons/org/ui/_site/script.js deleted file mode 100644 index 43e1821..0000000 --- a/arma/client/addons/org/ui/_site/script.js +++ /dev/null @@ -1,179 +0,0 @@ -/** - * Organization Dashboard - * Handles real-time updates and interactions - */ - -// Mock data for demonstration -const mockData = { - org: { - name: "Black Phoenix Initiative", - tag: "BPI-001", - status: "Active" - }, - stats: { - totalMembers: 24, - onlineMembers: 8, - balance: 125000, - reputation: 5 - }, - membersOnline: [ - { name: "John Doe", rank: "Leader", online: true }, - { name: "Jane Smith", rank: "Officer", online: true }, - { name: "Mike Johnson", rank: "Member", online: true }, - { name: "Sarah Wilson", rank: "Member", online: true }, - { name: "Alex Brown", rank: "Member", online: true }, - { name: "Chris Davis", rank: "Member", online: true }, - { name: "Pat Lee", rank: "Recruit", online: true }, - { name: "Sam Taylor", rank: "Recruit", online: true } - ], - activities: [ - { time: "2m ago", text: "Mike Johnson completed mission \"Alpha Strike\"" }, - { time: "15m ago", text: "Jane Smith deposited $5,000 to org bank" }, - { time: "1h ago", text: "New member Alex Brown joined the organization" }, - { time: "2h ago", text: "Organization captured territory: Zone-7" } - ], - assets: [ - { icon: "🏢", name: "Headquarters", location: "Downtown" }, - { icon: "🚁", name: "Helicopters", location: "3 units" }, - { icon: "🚗", name: "Vehicles", location: "12 units" }, - { icon: "📦", name: "Storage Units", location: "5 locations" } - ], - missions: [ - { - name: "Supply Run", - priority: "high", - description: "Deliver supplies to northern outpost", - progress: 65 - }, - { - name: "Recon Operation", - priority: "medium", - description: "Scout enemy positions in Zone-4", - progress: 30 - }, - { - name: "Territory Defense", - priority: "low", - description: "Maintain control of captured zones", - progress: 90 - } - ] -}; - -// Update dashboard with data -function updateDashboard(data) { - // Update header - if (data.org) { - const orgName = document.querySelector('.org-name'); - const orgTag = document.querySelector('.org-tag'); - if (orgName) orgName.textContent = data.org.name; - if (orgTag) orgTag.textContent = data.org.tag; - } - - // Update stats - if (data.stats) { - const statValues = document.querySelectorAll('.stat-value'); - if (statValues[0]) statValues[0].textContent = data.stats.totalMembers; - if (statValues[1]) statValues[1].textContent = data.stats.onlineMembers; - if (statValues[2]) statValues[2].textContent = `$${data.stats.balance.toLocaleString()}`; - if (statValues[3]) statValues[3].textContent = `Level ${data.stats.reputation}`; - } -} - -// Event handlers -function setupEventHandlers() { - // Close button - const closeBtn = document.querySelector('.close-btn'); - if (closeBtn) { - closeBtn.addEventListener('click', () => { - console.log('Closing dashboard...'); - // Send close event to Arma - if (typeof A3API !== 'undefined') { - A3API.SendAlert(JSON.stringify({ - event: 'org::close', - data: {} - })); - } - }); - } - - // Settings button - const settingsBtn = document.querySelectorAll('.action-btn')[0]; - if (settingsBtn && !settingsBtn.classList.contains('close-btn')) { - settingsBtn.addEventListener('click', () => { - console.log('Opening settings...'); - // Send settings event to Arma - if (typeof A3API !== 'undefined') { - A3API.SendAlert(JSON.stringify({ - event: 'org::settings', - data: {} - })); - } - }); - } - - // Member item clicks - const memberItems = document.querySelectorAll('.member-item'); - memberItems.forEach((item, index) => { - item.addEventListener('click', () => { - console.log('Member clicked:', mockData.membersOnline[index]); - if (typeof A3API !== 'undefined') { - A3API.SendAlert(JSON.stringify({ - event: 'org::member::view', - data: { member: mockData.membersOnline[index] } - })); - } - }); - }); - - // Asset item clicks - const assetItems = document.querySelectorAll('.asset-item'); - assetItems.forEach((item, index) => { - item.addEventListener('click', () => { - console.log('Asset clicked:', mockData.assets[index]); - if (typeof A3API !== 'undefined') { - A3API.SendAlert(JSON.stringify({ - event: 'org::asset::view', - data: { asset: mockData.assets[index] } - })); - } - }); - }); - - // Mission item clicks - const missionItems = document.querySelectorAll('.mission-item'); - missionItems.forEach((item, index) => { - item.addEventListener('click', () => { - console.log('Mission clicked:', mockData.missions[index]); - if (typeof A3API !== 'undefined') { - A3API.SendAlert(JSON.stringify({ - event: 'org::mission::view', - data: { mission: mockData.missions[index] } - })); - } - }); - }); -} - -// Initialize dashboard -function initDashboard() { - console.log('Organization Dashboard initializing...'); - - // Update with mock data - updateDashboard(mockData); - - // Setup event handlers - setupEventHandlers(); - - console.log('Organization Dashboard initialized'); -} - -// Auto-initialize when DOM is ready -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initDashboard); -} else { - initDashboard(); -} - -// Expose functions globally for Arma integration -window.updateOrgDashboard = updateDashboard; diff --git a/arma/client/addons/org/ui/_site/style.css b/arma/client/addons/org/ui/_site/style.css deleted file mode 100644 index a4f93c1..0000000 --- a/arma/client/addons/org/ui/_site/style.css +++ /dev/null @@ -1,469 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - height: 100vh; - width: 100vw; - background: rgba(0, 0, 0, 0.6); - font-family: Arial, sans-serif; - color: rgba(200, 220, 240, 0.95); - overflow: hidden; -} - -.dashboard-container { - height: 100vh; - width: 100vw; - padding: 2rem; - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -/* Header Section */ -.dashboard-header { - display: flex; - align-items: center; - gap: 1.5rem; - padding: 1.25rem 1.5rem; - background: rgba(15, 20, 30, 0.9); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - box-shadow: - 0 0 20px rgba(100, 150, 200, 0.15), - 0 4px 16px rgba(0, 0, 0, 0.8); -} - -.org-logo { - width: 60px; - height: 60px; - background: rgba(20, 30, 45, 0.8); - border: 2px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; -} - -.logo-placeholder { - font-size: 1.5rem; - font-weight: bold; - color: rgba(100, 150, 200, 0.9); -} - -.org-info { - flex: 1; -} - -.org-name { - font-size: 1.5rem; - font-weight: 600; - letter-spacing: 0.5px; - text-transform: uppercase; - color: rgba(200, 220, 255, 1); - margin-bottom: 0.25rem; -} - -.org-tag { - font-size: 0.875rem; - color: rgba(140, 160, 180, 0.8); - letter-spacing: 1px; -} - -.header-actions { - display: flex; - gap: 0.75rem; -} - -.action-btn { - padding: 0.625rem 1.25rem; - background: rgba(20, 30, 45, 0.7); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 4px; - color: rgba(200, 220, 240, 0.95); - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.5px; - cursor: pointer; - transition: all 0.15s ease; -} - -.action-btn:hover { - background: rgba(30, 45, 70, 0.9); - border-color: rgba(150, 200, 255, 0.7); - box-shadow: - 0 0 15px rgba(100, 150, 200, 0.2), - inset 0 0 20px rgba(100, 150, 200, 0.05); -} - -.close-btn { - border-color: rgba(200, 100, 100, 0.4); -} - -.close-btn:hover { - border-color: rgba(255, 100, 100, 0.7); - box-shadow: - 0 0 15px rgba(200, 100, 100, 0.2), - inset 0 0 20px rgba(200, 100, 100, 0.05); -} - -/* Dashboard Grid */ -.dashboard-grid { - flex: 1; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 1.25rem; - overflow-y: auto; - padding-right: 0.5rem; -} - -/* Custom Scrollbar */ -.dashboard-grid::-webkit-scrollbar { - width: 8px; -} - -.dashboard-grid::-webkit-scrollbar-track { - background: rgba(15, 20, 30, 0.5); - border-radius: 4px; -} - -.dashboard-grid::-webkit-scrollbar-thumb { - background: rgba(100, 150, 200, 0.3); - border-radius: 4px; -} - -.dashboard-grid::-webkit-scrollbar-thumb:hover { - background: rgba(100, 150, 200, 0.5); -} - -/* Dashboard Cards */ -.dashboard-card { - background: rgba(15, 20, 30, 0.9); - border: 1px solid rgba(100, 150, 200, 0.4); - border-left: 3px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; - padding: 1.25rem; - box-shadow: - 0 0 20px rgba(100, 150, 200, 0.1), - 0 4px 16px rgba(0, 0, 0, 0.6); - transition: all 0.2s ease; -} - -.dashboard-card:hover { - border-left-color: rgba(150, 200, 255, 0.8); - box-shadow: - 0 0 25px rgba(100, 150, 200, 0.2), - 0 4px 20px rgba(0, 0, 0, 0.7); -} - -.card-wide { - grid-column: span 2; -} - -.card-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1rem; - padding-bottom: 0.75rem; - border-bottom: 1px solid rgba(100, 150, 200, 0.2); -} - -.card-title { - font-size: 1rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(200, 220, 255, 1); -} - -.card-status { - padding: 0.25rem 0.75rem; - background: rgba(100, 200, 150, 0.2); - border: 1px solid rgba(100, 200, 150, 0.4); - border-radius: 3px; - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(150, 255, 200, 0.9); -} - -.card-badge { - padding: 0.25rem 0.625rem; - background: rgba(100, 150, 200, 0.2); - border: 1px solid rgba(100, 150, 200, 0.4); - border-radius: 3px; - font-size: 0.75rem; - font-weight: 600; - color: rgba(150, 200, 255, 0.9); -} - -.card-content { - color: rgba(180, 200, 220, 0.9); -} - -/* Stat Grid */ -.stat-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: 1.25rem; -} - -.stat-item { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding: 1rem; - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 4px; -} - -.stat-label { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.5px; - color: rgba(140, 160, 180, 0.85); -} - -.stat-value { - font-size: 1.75rem; - font-weight: 600; - color: rgba(200, 220, 255, 1); -} - -/* Member List */ -.member-list { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.member-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem; - background: rgba(20, 30, 45, 0.5); - border: 1px solid rgba(100, 150, 200, 0.2); - border-radius: 4px; - transition: all 0.15s ease; -} - -.member-item:hover { - background: rgba(30, 45, 70, 0.7); - border-color: rgba(100, 150, 200, 0.4); -} - -.member-status { - width: 10px; - height: 10px; - border-radius: 50%; - background: rgba(100, 100, 100, 0.5); -} - -.member-status.online { - background: rgba(100, 200, 150, 0.9); - box-shadow: 0 0 8px rgba(100, 200, 150, 0.5); -} - -.member-info { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.member-name { - font-size: 0.875rem; - font-weight: 500; - color: rgba(200, 220, 240, 0.95); -} - -.member-rank { - font-size: 0.75rem; - color: rgba(140, 160, 180, 0.8); -} - -/* Activity List */ -.activity-list { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.activity-item { - padding: 0.75rem; - background: rgba(20, 30, 45, 0.5); - border-left: 2px solid rgba(100, 150, 200, 0.4); - border-radius: 2px; -} - -.activity-time { - font-size: 0.7rem; - color: rgba(100, 150, 200, 0.7); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 0.375rem; -} - -.activity-text { - font-size: 0.875rem; - color: rgba(180, 200, 220, 0.9); - line-height: 1.4; -} - -/* Asset List */ -.asset-list { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.asset-item { - display: flex; - align-items: center; - gap: 1rem; - padding: 0.75rem; - background: rgba(20, 30, 45, 0.5); - border: 1px solid rgba(100, 150, 200, 0.2); - border-radius: 4px; - transition: all 0.15s ease; -} - -.asset-item:hover { - background: rgba(30, 45, 70, 0.7); - border-color: rgba(100, 150, 200, 0.4); -} - -.asset-icon { - font-size: 1.5rem; -} - -.asset-info { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.asset-name { - font-size: 0.875rem; - font-weight: 500; - color: rgba(200, 220, 240, 0.95); -} - -.asset-location { - font-size: 0.75rem; - color: rgba(140, 160, 180, 0.8); -} - -/* Mission List */ -.mission-list { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.mission-item { - padding: 1rem; - background: rgba(20, 30, 45, 0.6); - border: 1px solid rgba(100, 150, 200, 0.3); - border-left: 3px solid rgba(100, 150, 200, 0.5); - border-radius: 4px; -} - -.mission-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 0.5rem; -} - -.mission-name { - font-size: 0.95rem; - font-weight: 600; - color: rgba(200, 220, 255, 1); -} - -.mission-priority { - padding: 0.25rem 0.625rem; - border-radius: 3px; - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.5px; - font-weight: 600; -} - -.mission-priority.high { - background: rgba(200, 100, 100, 0.2); - border: 1px solid rgba(200, 100, 100, 0.4); - color: rgba(255, 150, 150, 0.9); -} - -.mission-priority.medium { - background: rgba(200, 150, 100, 0.2); - border: 1px solid rgba(200, 150, 100, 0.4); - color: rgba(255, 200, 150, 0.9); -} - -.mission-priority.low { - background: rgba(100, 150, 200, 0.2); - border: 1px solid rgba(100, 150, 200, 0.4); - color: rgba(150, 200, 255, 0.9); -} - -.mission-description { - font-size: 0.85rem; - color: rgba(160, 180, 200, 0.85); - margin-bottom: 0.75rem; - line-height: 1.4; -} - -.mission-progress { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.progress-bar { - flex: 1; - height: 6px; - background: rgba(20, 30, 45, 0.8); - border: 1px solid rgba(100, 150, 200, 0.3); - border-radius: 3px; - overflow: hidden; -} - -.progress-fill { - height: 100%; - background: linear-gradient(90deg, - rgba(100, 150, 200, 0.6), - rgba(150, 200, 255, 0.8)); - box-shadow: 0 0 10px rgba(100, 150, 200, 0.5); - transition: width 0.3s ease; -} - -.progress-text { - font-size: 0.75rem; - font-weight: 600; - color: rgba(100, 150, 200, 0.9); - min-width: 40px; -} - -/* Responsive adjustments */ -@media (max-width: 1200px) { - .card-wide { - grid-column: span 1; - } -} - -@media (max-width: 768px) { - .dashboard-container { - padding: 1rem; - } - - .dashboard-grid { - grid-template-columns: 1fr; - } -} diff --git a/arma/client/addons/org/ui/src/bootstrap.js b/arma/client/addons/org/ui/src/bootstrap.js new file mode 100644 index 0000000..474ba30 --- /dev/null +++ b/arma/client/addons/org/ui/src/bootstrap.js @@ -0,0 +1,114 @@ +(function () { + const ForgeWebUI = window.ForgeWebUI; + const RegistryApp = window.RegistryApp; + const OrgPortal = window.OrgPortal; + const islandDefinitions = [ + { + id: "org-portal-frame-root", + preserveScroll: true, + render: () => OrgPortal.components.App(), + }, + { + id: "org-portal-toast-root", + preserveScroll: false, + render: () => OrgPortal.componentFns.TreasuryNoticeLayer(), + }, + { + id: "org-overview-card-root", + preserveScroll: false, + render: () => OrgPortal.componentFns.OverviewCard(), + }, + { + id: "org-fleet-card-root", + preserveScroll: true, + render: () => OrgPortal.componentFns.FleetCard(), + }, + { + id: "org-treasury-card-root", + preserveScroll: false, + render: () => OrgPortal.componentFns.TreasuryCard(), + }, + { + id: "org-members-card-root", + preserveScroll: true, + render: () => OrgPortal.componentFns.MembersCard(), + }, + { + id: "org-assets-card-root", + preserveScroll: true, + render: () => OrgPortal.componentFns.AssetsCard(), + }, + { + id: "org-activity-card-root", + preserveScroll: true, + render: () => OrgPortal.componentFns.ActivityCard(), + }, + { + id: "org-portal-modal-root", + preserveScroll: false, + render: () => OrgPortal.componentFns.ModalLayer(), + }, + ]; + + function createIslandManager() { + const mounts = new Map(); + + function sync() { + islandDefinitions.forEach((definition) => { + const container = document.getElementById(definition.id); + const current = mounts.get(definition.id); + + if (!container) { + if (current) { + current.handle.dispose(); + mounts.delete(definition.id); + } + return; + } + + if (current && current.container === container) { + return; + } + + if (current) { + current.handle.dispose(); + } + + const handle = ForgeWebUI.mount(container, definition.render, { + preserveScroll: definition.preserveScroll, + }); + mounts.set(definition.id, { + container, + handle, + }); + }); + } + + return { + sync, + }; + } + + const app = ForgeWebUI.createApp({ + name: "org", + root: "#app", + setup({ root }) { + const islandManager = createIslandManager(); + + ForgeWebUI.mount(root, () => RegistryApp.components.App(), { + preserveScroll: false, + }); + RegistryApp.bridge.ready({ loaded: true }); + + ForgeWebUI.effect(() => { + RegistryApp.store.getView(); + + requestAnimationFrame(() => { + islandManager.sync(); + }); + }); + }, + }); + + app.start(); +})(); diff --git a/arma/client/addons/org/ui/src/bridge.js b/arma/client/addons/org/ui/src/bridge.js new file mode 100644 index 0000000..cfa10ae --- /dev/null +++ b/arma/client/addons/org/ui/src/bridge.js @@ -0,0 +1,229 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const store = RegistryApp.store; + const bridge = window.ForgeWebUI.createBridge({ + closeEvent: "org::close", + globalName: "ForgeBridge", + readyEvent: "org::ready", + }); + + function sendEvent(event, data) { + return bridge.send(event, data); + } + + function requestLogin(credentials) { + store.startLogin(); + + const sent = sendEvent("org::login::request", credentials); + if (sent) { + return; + } + + store.failLogin("Arma login bridge is unavailable."); + } + + function requestCreateOrg(registration) { + store.startCreate(); + + const sent = sendEvent("org::create::request", registration); + if (sent) { + return; + } + + store.failCreate("Arma registration bridge is unavailable."); + } + + function requestDisbandOrg() { + const sent = sendEvent("org::disband::request", {}); + if (sent) { + return; + } + + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + "Arma disband bridge is unavailable.", + ); + } + } + + function requestLeaveOrg() { + const sent = sendEvent("org::leave::request", {}); + if (sent) { + return; + } + + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + "Arma leave bridge is unavailable.", + ); + } + } + + function requestCreditLine(payload) { + const sent = sendEvent("org::credit::request", payload); + if (sent) { + return true; + } + + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + "Arma credit line bridge is unavailable.", + ); + } + + return false; + } + + bridge.on("org::login::success", (payloadData) => { + store.completeLogin(payloadData); + }); + + bridge.on("org::login::failure", (payloadData) => { + store.failLogin(payloadData.message || "Authentication failed."); + }); + + bridge.on("org::create::success", (payloadData) => { + store.completeCreate(payloadData); + }); + + bridge.on("org::create::failure", (payloadData) => { + store.failCreate( + payloadData.message || "Organization registration failed.", + ); + }); + + bridge.on("org::sync", (payloadData) => { + if (store && typeof store.hydratePortal === "function") { + store.hydratePortal(payloadData); + } + }); + + bridge.on("org::credit::success", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "success", + payloadData.message || "Credit line assigned.", + ); + } + }); + + bridge.on("org::credit::failure", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + payloadData.message || "Unable to assign credit line.", + ); + } + }); + + bridge.on("org::member::creditUpdated", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (!OrgPortal || !OrgPortal.store) { + return; + } + + OrgPortal.store.setCreditLines((currentLines) => { + const nextLine = { + amount: payloadData.amount || 0, + member: payloadData.memberName || "", + uid: payloadData.memberUid || "", + }; + const matchIndex = currentLines.findIndex( + (line) => line.uid === nextLine.uid, + ); + + if (matchIndex === -1) { + return [...currentLines, nextLine]; + } + + return currentLines.map((line, index) => + index === matchIndex ? nextLine : line, + ); + }); + }); + + bridge.on("org::disband::success", () => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + OrgPortal.store.setOrgDisbanded(true); + } + }); + + bridge.on("org::disband::failure", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + payloadData.message || "Organization disbanding failed.", + ); + } + }); + + bridge.on("org::leave::success", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + store.failLogin( + payloadData.message || "You have left the organization.", + ); + store.setView("home"); + }); + + bridge.on("org::leave::failure", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + if (OrgPortal && OrgPortal.actions) { + OrgPortal.actions.showTreasuryNotice( + "error", + payloadData.message || "Unable to leave the organization.", + ); + } + }); + + bridge.on("org::portal::revoked", (payloadData) => { + const OrgPortal = window.OrgPortal; + if (OrgPortal && OrgPortal.store) { + OrgPortal.store.setModal(null); + } + + store.failLogin( + payloadData.message || + "Organization access is no longer available.", + ); + store.setView("home"); + }); + + RegistryApp.bridge = { + close: bridge.close, + ready: bridge.ready, + receive: bridge.receive, + requestLogin, + requestCreateOrg, + requestDisbandOrg, + requestLeaveOrg, + requestCreditLine, + sendEvent, + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/AppShell.js b/arma/client/addons/org/ui/src/components/AppShell.js new file mode 100644 index 0000000..185b369 --- /dev/null +++ b/arma/client/addons/org/ui/src/components/AppShell.js @@ -0,0 +1,134 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h } = RegistryApp.runtime; + const store = RegistryApp.store; + + RegistryApp.components = RegistryApp.components || {}; + + RegistryApp.components.App = function App() { + const Navbar = window.SharedUI.componentFns.Navbar; + const Header = window.SharedUI.componentFns.Header; + const Footer = window.SharedUI.componentFns.Footer; + const WindowTitleBar = window.SharedUI.componentFns.WindowTitleBar; + const HomeView = RegistryApp.componentFns.HomeView; + const RegistrationView = RegistryApp.componentFns.RegistrationView; + const PortalApp = + window.OrgPortal && window.OrgPortal.components + ? window.OrgPortal.components.App + : null; + + const view = store.getView(); + const portalGetters = + window.OrgPortal && window.OrgPortal.getters + ? window.OrgPortal.getters + : null; + const portalActions = + window.OrgPortal && window.OrgPortal.actions + ? window.OrgPortal.actions + : null; + const viewLabel = + view === "create" + ? "Organization Registration" + : view === "portal" + ? "Organization Portal" + : "Entry Hub"; + const footerSections = [ + { + title: "Registry Resources", + items: [ + "Registration Guidelines", + "Tax & Fee Schedule", + "Legal Compliance", + "Trademark Database", + ], + }, + { + title: "Bureau Support", + items: [ + "Office: Sector 7 Admin Block", + "Hours: 0800 - 1600 (GST)", + "Helpdesk: 555-01-REGISTRY", + "support@org-bureau.gov", + ], + }, + ]; + + function closeRegistry() { + if ( + RegistryApp.bridge && + typeof RegistryApp.bridge.close === "function" + ) { + RegistryApp.bridge.close({}); + return; + } + + store.setView("home"); + } + + if (view === "portal" && PortalApp) { + const canLeaveOrg = + portalGetters && + typeof portalGetters.canLeaveOrg === "function" && + portalGetters.canLeaveOrg(); + + return h( + "div", + { className: "app-shell" }, + WindowTitleBar({ + kicker: "FORGE ORBIS", + title: "Global Organization Network", + onClose: closeRegistry, + closeLabel: "Close organization interface", + }), + Navbar({ + title: "Global Organization Network", + viewLabel, + actionLabel: canLeaveOrg ? "Leave Organization" : "", + onAction: + canLeaveOrg && + portalActions && + typeof portalActions.openModal === "function" + ? () => portalActions.openModal("leave") + : null, + }), + h("div", { id: "org-portal-frame-root" }), + ); + } + + let mainContent; + if (view === "home") { + mainContent = HomeView(); + } else if (view === "create") { + mainContent = RegistrationView(); + } + + return h( + "div", + { className: "app-shell" }, + WindowTitleBar({ + kicker: "FORGE ORBIS", + title: "Global Organization Network", + onClose: closeRegistry, + closeLabel: "Close organization interface", + }), + h( + "main", + null, + Navbar({ + title: "Global Organization Network", + viewLabel, + }), + h( + "div", + { className: "container" }, + Header({ + title: "Global Organization Network", + onTitleClick: () => store.setView("home"), + }), + mainContent, + ), + Footer({ sections: footerSections }), + ), + ); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/footer.js b/arma/client/addons/org/ui/src/components/footer.js new file mode 100644 index 0000000..4401f4b --- /dev/null +++ b/arma/client/addons/org/ui/src/components/footer.js @@ -0,0 +1,32 @@ +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h } = RegistryApp.runtime; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Footer = function Footer({ sections = [] }) { + return h( + "div", + { className: "footer" }, + h( + "div", + { className: "wrapper" }, + ...sections.map((section) => + h( + "div", + null, + h("h3", null, section.title), + h( + "ul", + { style: { listStyleType: "none", padding: 0 } }, + ...(section.items || []).map((item) => + h("li", null, item), + ), + ), + ), + ), + ), + ); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/header.js b/arma/client/addons/org/ui/src/components/header.js new file mode 100644 index 0000000..6734ac1 --- /dev/null +++ b/arma/client/addons/org/ui/src/components/header.js @@ -0,0 +1,27 @@ +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h } = RegistryApp.runtime; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Header = function Header({ + title, + subtitle = "Organization Registration & Management Portal", + onTitleClick = null, + }) { + return h( + "div", + { className: "header" }, + h( + "h1", + { + style: { cursor: onTitleClick ? "pointer" : "default" }, + onClick: onTitleClick, + }, + title, + ), + h("p", null, subtitle), + ); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/hero.js b/arma/client/addons/org/ui/src/components/hero.js new file mode 100644 index 0000000..a022e70 --- /dev/null +++ b/arma/client/addons/org/ui/src/components/hero.js @@ -0,0 +1,35 @@ +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h } = OrgPortal.runtime; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Hero = function Hero({ + className = "", + kicker = "", + title = "", + subtitle = "", + meta = "", + }) { + const finalClassName = [ + "card org-panel org-span-12 org-page-header", + className, + ] + .filter(Boolean) + .join(" "); + + return h( + "section", + { className: finalClassName }, + h( + "div", + { className: "org-page-heading" }, + h("span", { className: "org-page-kicker" }, kicker), + h("h1", { className: "org-page-title" }, title), + h("p", { className: "org-page-subtitle" }, subtitle), + h("span", { className: "org-page-meta" }, meta), + ), + ); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/modal.js b/arma/client/addons/org/ui/src/components/modal.js new file mode 100644 index 0000000..862b115 --- /dev/null +++ b/arma/client/addons/org/ui/src/components/modal.js @@ -0,0 +1,190 @@ +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const scopeAttr = "data-ui-modal"; + const scopeSelector = `[${scopeAttr}]`; + const modalCss = ` +${scopeSelector} { + position: fixed; + inset: 0; + background: rgb(15 23 42 / 0.38); + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + z-index: 20; +} + +${scopeSelector} .app-modal-card { + width: min(100%, 30rem); + margin-bottom: 0; + text-align: left; +} + +${scopeSelector} .app-modal-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +${scopeSelector} .app-modal-title { + margin: 0; + color: var(--primary-hover); + font-size: 1.45rem; +} + +${scopeSelector} .app-modal-close { + width: 2.25rem; + height: 2.25rem; + padding: 0; + background: var(--bg-surface); + color: var(--text-main); + border: 1px solid var(--border); + box-shadow: none; + transform: none; +} + +${scopeSelector} .app-modal-close:hover { + background: var(--bg-surface-hover); + color: var(--text-main); + box-shadow: none; + transform: none; +} + +${scopeSelector} .app-modal-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +${scopeSelector} .app-modal-form label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-muted); + font-weight: 500; + font-size: 0.9rem; +} + +${scopeSelector} .app-modal-form input, +${scopeSelector} .app-modal-form select { + width: 100%; + padding: 0.75rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-app); + color: var(--text-main); + font-family: inherit; + font-size: 1rem; + box-sizing: border-box; + transition: border-color 0.2s, box-shadow 0.2s; +} + +${scopeSelector} .app-modal-form input:focus, +${scopeSelector} .app-modal-form select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgb(71 85 105 / 0.12); +} + +${scopeSelector} .app-modal-form input:disabled, +${scopeSelector} .app-modal-form select:disabled { + background: #f1f5f9; + color: var(--text-muted); + cursor: not-allowed; +} + +${scopeSelector} .app-modal-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 0.5rem; +} + +${scopeSelector} .app-modal-actions button + button, +${scopeSelector} .app-modal-danger-actions button + button { + margin-left: 0; +} + +${scopeSelector} .app-modal-danger { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border: 1px solid #fecaca; + border-radius: var(--radius); + background: #fff1f2; + align-items: flex-start; +} + +${scopeSelector} .app-modal-danger p { + margin: 0; + color: var(--text-main); +} + +${scopeSelector} .app-modal-danger-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .app-modal-head, + ${scopeSelector} .app-modal-danger { + flex-direction: column; + align-items: flex-start; + } +} +`; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Modal = function Modal({ + title = "", + body = null, + onClose = null, + }) { + ensureScopedStyle("shared-modal", modalCss); + + return h( + "div", + { + className: "app-modal-backdrop", + [scopeAttr]: "", + onClick: (e) => { + if (e.target === e.currentTarget && onClose) { + onClose(); + } + }, + }, + h( + "div", + { className: "card app-modal-card" }, + h( + "div", + { className: "app-modal-head" }, + h( + "div", + null, + h("h2", { className: "app-modal-title" }, title), + ), + h( + "button", + { + type: "button", + className: "app-modal-close", + onClick: onClose, + "aria-label": "Close dialog", + }, + "x", + ), + ), + body, + ), + ); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/navbar.js b/arma/client/addons/org/ui/src/components/navbar.js new file mode 100644 index 0000000..427841b --- /dev/null +++ b/arma/client/addons/org/ui/src/components/navbar.js @@ -0,0 +1,131 @@ +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const scopeAttr = "data-ui-navbar"; + const scopeSelector = `[${scopeAttr}]`; + const navbarCss = ` +${scopeSelector} { + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow); +} + +${scopeSelector} .app-navbar-inner { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 1rem 2rem; + box-sizing: border-box; +} + +${scopeSelector} .app-navbar-brand { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +${scopeSelector} .app-navbar-kicker { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 600; +} + +${scopeSelector} .app-navbar-title { + font-size: 1.25rem; + font-weight: 700; + color: var(--primary-hover); + letter-spacing: -0.025em; +} + +${scopeSelector} .app-navbar-actions { + display: flex; + align-items: center; + gap: 1.5rem; +} + +${scopeSelector} .app-navbar-view { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + font-weight: 600; +} + +${scopeSelector} .app-close-btn { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border); + padding: 0.5rem 1rem; + font-size: 0.85rem; +} + +${scopeSelector} .app-close-btn:hover { + background: var(--bg-surface-hover); + color: var(--primary-hover); + border-color: var(--primary); + transform: none; + box-shadow: none; +} + +@media (max-width: 960px) { + ${scopeSelector} .app-navbar-inner { + flex-direction: column; + align-items: flex-start; + padding: 1rem 1.5rem; + } + + ${scopeSelector} .app-navbar-actions { + align-items: flex-start; + } +} +`; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Navbar = function Navbar({ + kicker = "ORBIS", + title = "", + viewLabel = "", + actionLabel = "", + onAction = null, + }) { + ensureScopedStyle("shared-navbar", navbarCss); + + return h( + "nav", + { className: "app-navbar", [scopeAttr]: "" }, + h( + "div", + { className: "app-navbar-inner" }, + h( + "div", + { className: "app-navbar-brand" }, + h("span", { className: "app-navbar-kicker" }, kicker), + h("span", { className: "app-navbar-title" }, title), + ), + h( + "div", + { className: "app-navbar-actions" }, + h("span", { className: "app-navbar-view" }, viewLabel), + actionLabel && typeof onAction === "function" + ? h( + "button", + { + type: "button", + className: "app-close-btn", + onClick: onAction, + }, + actionLabel, + ) + : null, + ), + ), + ); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/panelCard.js b/arma/client/addons/org/ui/src/components/panelCard.js new file mode 100644 index 0000000..02b6cae --- /dev/null +++ b/arma/client/addons/org/ui/src/components/panelCard.js @@ -0,0 +1,97 @@ +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const scopeAttr = "data-ui-panel-card"; + const scopeSelector = `[${scopeAttr}]`; + const panelCardCss = ` +${scopeSelector} { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +${scopeSelector} .org-panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; +} + +${scopeSelector} .org-panel-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; +} + +${scopeSelector} .org-eyebrow { + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 0.4rem; +} + +${scopeSelector} .org-panel-title { + margin: 0; + color: var(--primary-hover); + font-size: 1.45rem; +} + +${scopeSelector} .org-panel-subtitle { + margin: 0.35rem 0 0; + color: var(--text-muted); + font-size: 0.95rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-panel-head { + flex-direction: column; + align-items: flex-start; + } +} +`; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.PanelCard = function PanelCard({ + className = "", + eyebrow = "", + title = "", + subtitle = "", + headerExtras = null, + body = null, + rootProps = {}, + }) { + const finalClassName = ["card org-panel", className] + .filter(Boolean) + .join(" "); + ensureScopedStyle("shared-panel-card", panelCardCss); + + return h( + "section", + { className: finalClassName, [scopeAttr]: "", ...rootProps }, + h( + "div", + { className: "org-panel-head" }, + h( + "div", + null, + eyebrow + ? h("div", { className: "org-eyebrow" }, eyebrow) + : null, + h("h2", { className: "org-panel-title" }, title), + subtitle + ? h("p", { className: "org-panel-subtitle" }, subtitle) + : null, + ), + headerExtras, + ), + h("div", { className: "org-panel-body" }, body), + ); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/portal/activityCard.js b/arma/client/addons/org/ui/src/components/portal/activityCard.js new file mode 100644 index 0000000..19316aa --- /dev/null +++ b/arma/client/addons/org/ui/src/components/portal/activityCard.js @@ -0,0 +1,80 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const scopeAttr = "data-ui-activity-card"; + const scopeSelector = `[${scopeAttr}]`; + const activityCardCss = ` +${scopeSelector} .org-activity-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 0.85rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-activity-row { + padding: 1rem; + border: 1px solid var(--border); + border-left: 3px solid #94a3b8; + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-activity-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); + border-left-color: #64748b; +} + +${scopeSelector} .org-activity-row p { + margin: 0; + color: var(--text-main); +} + +${scopeSelector} .org-activity-time { + display: inline-block; + margin-bottom: 0.35rem; + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.ActivityCard = function ActivityCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + const activity = OrgPortal.store.getActivity(); + ensureScopedStyle("portal-activity-card", activityCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-6", + title: "Command Feed", + subtitle: "Recent organization-level actions and updates.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-activity-list" }, + ...activity.map((item) => + h( + "article", + { className: "org-activity-row" }, + h( + "span", + { className: "org-activity-time" }, + item.time, + ), + h("p", null, item.text), + ), + ), + ), + }); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/portal/assetsCard.js b/arma/client/addons/org/ui/src/components/portal/assetsCard.js new file mode 100644 index 0000000..f33ea67 --- /dev/null +++ b/arma/client/addons/org/ui/src/components/portal/assetsCard.js @@ -0,0 +1,95 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const getters = OrgPortal.getters; + const scopeAttr = "data-ui-assets-card"; + const scopeSelector = `[${scopeAttr}]`; + const assetsCardCss = ` +${scopeSelector} .org-simple-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 0.85rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-simple-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-simple-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-simple-name { + color: var(--primary-hover); +} + +${scopeSelector} .org-simple-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 1rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-simple-row { + flex-direction: column; + align-items: flex-start; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.AssetsCard = function AssetsCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + const SimpleStat = OrgPortal.componentFns.SimpleStat; + const assets = OrgPortal.store.getAssets(); + ensureScopedStyle("portal-assets-card", assetsCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-7", + title: "Assets", + subtitle: "Inventory supplies and equipment with quantity totals.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-simple-list" }, + ...assets.map((asset) => + h( + "article", + { className: "org-simple-row" }, + h( + "strong", + { className: "org-simple-name" }, + asset.name, + ), + h( + "div", + { className: "org-simple-meta" }, + SimpleStat( + "Type", + getters.formatAssetType(asset.type), + ), + SimpleStat("Quantity", asset.quantity), + ), + ), + ), + ), + }); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/portal/dangerCard.js b/arma/client/addons/org/ui/src/components/portal/dangerCard.js new file mode 100644 index 0000000..22027fb --- /dev/null +++ b/arma/client/addons/org/ui/src/components/portal/dangerCard.js @@ -0,0 +1,70 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const getters = OrgPortal.getters; + const actions = OrgPortal.actions; + const scopeAttr = "data-ui-danger-card"; + const scopeSelector = `[${scopeAttr}]`; + const dangerCardCss = ` +${scopeSelector} { + border-color: #fecaca; + background: linear-gradient(180deg, #ffffff 0%, #fff7f7 100%); +} + +${scopeSelector} .org-danger-copy { + margin-bottom: 1rem; +} + +${scopeSelector} .org-danger-copy strong, +${scopeSelector} .org-danger-copy p { + display: block; +} + +${scopeSelector} .org-danger-copy p { + margin: 0.4rem 0 0; + color: var(--text-muted); +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.DangerCard = function DangerCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + ensureScopedStyle("portal-danger-card", dangerCardCss); + + if (!getters.canDisbandOrg()) { + return null; + } + + return PanelCard({ + className: "org-span-12 org-danger-panel", + title: "Organization Controls", + subtitle: + "Leader-only actions for membership and permanent organization removal.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + null, + h( + "div", + { className: "org-danger-copy" }, + h("strong", null, "Disband organization"), + h( + "p", + null, + "This removes the organization and revokes access to the portal for all members.", + ), + ), + h( + "button", + { + type: "button", + className: "org-danger-btn", + onClick: () => actions.openModal("disband"), + }, + "Disband Organization", + ), + ), + }); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/portal/fleetCard.js b/arma/client/addons/org/ui/src/components/portal/fleetCard.js new file mode 100644 index 0000000..a6b368b --- /dev/null +++ b/arma/client/addons/org/ui/src/components/portal/fleetCard.js @@ -0,0 +1,97 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const getters = OrgPortal.getters; + const scopeAttr = "data-ui-fleet-card"; + const scopeSelector = `[${scopeAttr}]`; + const fleetCardCss = ` +${scopeSelector} .org-simple-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 0.85rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-simple-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-simple-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-simple-name { + color: var(--primary-hover); +} + +${scopeSelector} .org-simple-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 1rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-simple-row { + flex-direction: column; + align-items: flex-start; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.FleetCard = function FleetCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + const SimpleStat = OrgPortal.componentFns.SimpleStat; + const fleet = OrgPortal.store.getFleet(); + ensureScopedStyle("portal-fleet-card", fleetCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-7", + title: "Fleet", + subtitle: + "Individual vehicles with type, status, and overall damage.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-simple-list" }, + ...fleet.map((unit) => + h( + "article", + { className: "org-simple-row" }, + h( + "strong", + { className: "org-simple-name" }, + unit.name, + ), + h( + "div", + { className: "org-simple-meta" }, + SimpleStat( + "Type", + getters.formatVehicleType(unit.type), + ), + SimpleStat("Status", unit.status), + SimpleStat("Damage", unit.damage), + ), + ), + ), + ), + }); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/portal/futureCard.js b/arma/client/addons/org/ui/src/components/portal/futureCard.js new file mode 100644 index 0000000..343835f --- /dev/null +++ b/arma/client/addons/org/ui/src/components/portal/futureCard.js @@ -0,0 +1,126 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const scopeAttr = "data-ui-future-card"; + const ROADMAP = [ + { + name: "Contracts Board", + status: "Planned", + detail: "Track payouts, assignments, and claim approvals.", + }, + { + name: "Diplomacy", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + { + name: "Logistics Queue", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + { + name: "Permissions", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + ]; + const scopeSelector = `[${scopeAttr}]`; + const futureCardCss = ` +${scopeSelector} .org-roadmap-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + flex: 1; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-roadmap-card { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.7rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-roadmap-card:nth-child(4n + 2), +${scopeSelector} .org-roadmap-card:nth-child(4n + 3) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(100 116 139 / 0.4); +} + +${scopeSelector} .org-roadmap-card p { + margin: 0; + color: var(--text-main); +} + +${scopeSelector} .org-list-tag { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + background: #e2e8f0; + color: var(--primary-hover); +} + +${scopeSelector} .org-roadmap-card:nth-child(4n + 2) .org-list-tag, +${scopeSelector} .org-roadmap-card:nth-child(4n + 3) .org-list-tag { + background: #cbd5e1; + color: #1e293b; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-roadmap-grid { + grid-template-columns: 1fr; + } + + ${scopeSelector} .org-roadmap-card:nth-child(4n + 3) { + background: #f8fafc; + border-color: var(--border); + } + + ${scopeSelector} .org-roadmap-card:nth-child(4n + 3) .org-list-tag { + background: #e2e8f0; + color: var(--primary-hover); + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.FutureCard = function FutureCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + ensureScopedStyle("portal-future-card", futureCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-6", + title: "Expansion Slots", + subtitle: + "Potential modules are tagged by status such as Planned, In Design, In Review, and Future Review.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-roadmap-grid" }, + ...ROADMAP.map((item) => + h( + "article", + { className: "org-roadmap-card" }, + h("span", { className: "org-list-tag" }, item.status), + h("strong", null, item.name), + h("p", null, item.detail), + ), + ), + ), + }); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/portal/membersCard.js b/arma/client/addons/org/ui/src/components/portal/membersCard.js new file mode 100644 index 0000000..6108286 --- /dev/null +++ b/arma/client/addons/org/ui/src/components/portal/membersCard.js @@ -0,0 +1,116 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const store = OrgPortal.store; + const getters = OrgPortal.getters; + const actions = OrgPortal.actions; + const scopeAttr = "data-ui-members-card"; + const scopeSelector = `[${scopeAttr}]`; + const membersCardCss = ` +${scopeSelector} .org-name-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 0.85rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-name-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-name-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-name-row button { + margin-left: auto; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-name-row { + flex-direction: column; + align-items: flex-start; + } + + ${scopeSelector} .org-name-row button { + margin-left: 0; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.MembersCard = function MembersCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + const members = store.getMembers(); + const allowMemberManagement = getters.canManageMembers(); + ensureScopedStyle("portal-members-card", membersCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-5", + title: "Members", + subtitle: + "Current roster listing. The organization owner and your own member entry cannot be removed.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-name-list" }, + ...members.map((member) => { + const canRemoveMember = + allowMemberManagement && + !getters.isProtectedMember(member); + + return h( + "article", + { className: "org-name-row" }, + h("strong", null, member.name), + canRemoveMember + ? h( + "button", + { + type: "button", + className: "org-danger-btn org-icon-btn", + title: `Remove ${member.name}`, + "aria-label": `Remove ${member.name}`, + onClick: () => + actions.removeMember(member), + }, + h( + "svg", + { + className: "org-icon", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "aria-hidden": "true", + }, + h("path", { d: "M9 3h6" }), + h("path", { d: "M4 7h16" }), + h("path", { d: "M6 7l1 13h10l1-13" }), + h("path", { d: "M10 11v6" }), + h("path", { d: "M14 11v6" }), + ), + ) + : null, + ); + }), + ), + }); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/portal/metricCard.js b/arma/client/addons/org/ui/src/components/portal/metricCard.js new file mode 100644 index 0000000..ed0d7a3 --- /dev/null +++ b/arma/client/addons/org/ui/src/components/portal/metricCard.js @@ -0,0 +1,77 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const scopeAttr = "data-ui-metric-card"; + const scopeSelector = `[${scopeAttr}]`; + const metricCardCss = ` +${scopeSelector} { + display: flex; + flex-direction: column; + gap: 0.45rem; + padding: 1rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); +} + +${scopeSelector}:nth-child(4n + 2), +${scopeSelector}:nth-child(4n + 3) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(226 232 240) 100%); + border-color: rgb(100 116 139 / 0.35); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6); +} + +${scopeSelector} .org-metric-label { + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} + +${scopeSelector} .org-metric-value { + font-size: 1.8rem; + color: var(--primary-hover); + line-height: 1.1; +} + +${scopeSelector}:nth-child(4n + 2) .org-metric-value, +${scopeSelector}:nth-child(4n + 3) .org-metric-value { + color: #334155; +} + +${scopeSelector} .org-metric-note { + color: var(--text-muted); + font-size: 0.9rem; +} + +@media (max-width: 960px) { + ${scopeSelector}:nth-child(4n + 3) { + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + border-color: var(--border); + box-shadow: none; + } + + ${scopeSelector}:nth-child(4n + 3) .org-metric-value { + color: var(--primary-hover); + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.MetricCard = function MetricCard( + label, + value, + note, + ) { + ensureScopedStyle("portal-metric-card", metricCardCss); + + return h( + "div", + { className: "org-metric-card", [scopeAttr]: "" }, + h("span", { className: "org-metric-label" }, label), + h("strong", { className: "org-metric-value" }, value), + h("span", { className: "org-metric-note" }, note), + ); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/portal/modalLayer.js b/arma/client/addons/org/ui/src/components/portal/modalLayer.js new file mode 100644 index 0000000..db83264 --- /dev/null +++ b/arma/client/addons/org/ui/src/components/portal/modalLayer.js @@ -0,0 +1,292 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const store = OrgPortal.store; + const actions = OrgPortal.actions; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.ModalLayer = function ModalLayer() { + const Modal = window.SharedUI.componentFns.Modal; + const modal = store.getModal(); + if (!modal) { + return null; + } + + const members = store.getMembers(); + const memberSelectProps = + members.length === 0 ? { disabled: true } : {}; + + let title = ""; + let body = null; + + if (modal.type === "payroll") { + title = "Run Payroll"; + body = h( + "div", + { className: "app-modal-form" }, + h( + "div", + null, + h("label", null, "Amount Per Member"), + h("input", { + id: "treasury-payroll-amount", + type: "number", + min: "1", + placeholder: "500", + autofocus: "true", + }), + ), + h( + "div", + { className: "app-modal-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + onClick: () => { + if ( + actions.runPayroll( + actions.parseAmount( + actions.getInputValue( + "treasury-payroll-amount", + ), + ), + ) + ) { + actions.closeModal(); + } + }, + }, + "Run Payroll", + ), + ), + ); + } else if (modal.type === "transfer") { + title = "Send Funds"; + body = h( + "div", + { className: "app-modal-form" }, + h( + "div", + null, + h("label", null, "Member"), + h( + "select", + { + id: "treasury-transfer-member", + ...memberSelectProps, + }, + ...members.map((member) => + h("option", { value: member.name }, member.name), + ), + ), + ), + h( + "div", + null, + h("label", null, "Amount"), + h("input", { + id: "treasury-transfer-amount", + type: "number", + min: "1", + placeholder: "1500", + }), + ), + h( + "div", + { className: "app-modal-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + ...memberSelectProps, + onClick: () => { + if ( + actions.sendFundsToMember( + String( + actions.getInputValue( + "treasury-transfer-member", + ) || "", + ), + actions.parseAmount( + actions.getInputValue( + "treasury-transfer-amount", + ), + ), + ) + ) { + actions.closeModal(); + } + }, + }, + "Send Funds", + ), + ), + ); + } else if (modal.type === "credit") { + title = "Assign Credit Line"; + body = h( + "div", + { className: "app-modal-form" }, + h( + "div", + null, + h("label", null, "Member"), + h( + "select", + { id: "treasury-credit-member", ...memberSelectProps }, + ...members.map((member) => + h("option", { value: member.uid }, member.name), + ), + ), + ), + h( + "div", + null, + h("label", null, "Credit Amount"), + h("input", { + id: "treasury-credit-amount", + type: "number", + min: "1", + placeholder: "5000", + }), + ), + h( + "div", + { className: "app-modal-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + ...memberSelectProps, + onClick: () => { + if ( + actions.grantCreditLine( + String( + actions.getInputValue( + "treasury-credit-member", + ) || "", + ), + actions.parseAmount( + actions.getInputValue( + "treasury-credit-amount", + ), + ), + ) + ) { + actions.closeModal(); + } + }, + }, + "Assign Credit Line", + ), + ), + ); + } else if (modal.type === "disband") { + title = "Disband Organization"; + body = h( + "div", + { className: "app-modal-danger" }, + h( + "p", + null, + "This action is permanent. Disband ", + portalData.org.name, + "?", + ), + h( + "div", + { className: "app-modal-danger-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + className: "org-danger-btn", + onClick: () => actions.disbandOrganization(), + }, + "Confirm Disband", + ), + ), + ); + } else if (modal.type === "leave") { + title = "Leave Organization"; + body = h( + "div", + { className: "app-modal-danger" }, + h( + "p", + null, + "Leave ", + portalData.org.name, + " and return to the default organization?", + ), + h( + "div", + { className: "app-modal-danger-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + className: "org-danger-btn", + onClick: () => actions.leaveOrganization(), + }, + "Confirm Leave", + ), + ), + ); + } + + return Modal({ + title, + body, + onClose: () => actions.closeModal(), + }); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/portal/overviewCard.js b/arma/client/addons/org/ui/src/components/portal/overviewCard.js new file mode 100644 index 0000000..050a97f --- /dev/null +++ b/arma/client/addons/org/ui/src/components/portal/overviewCard.js @@ -0,0 +1,180 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const store = OrgPortal.store; + const getters = OrgPortal.getters; + const scopeAttr = "data-ui-overview-card"; + const scopeSelector = `[${scopeAttr}]`; + const overviewCardCss = ` +${scopeSelector} .org-hero-grid { + display: grid; + grid-template-columns: 1.3fr 1fr; + gap: 1.5rem; + align-items: start; +} + +${scopeSelector} .org-summary { + margin: 0; + font-size: 1.05rem; + color: var(--text-main); +} + +${scopeSelector} .org-meta-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +${scopeSelector} .org-meta-item { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-meta-item:nth-child(even) { + background: linear-gradient(180deg, rgb(241 245 249) 0%, rgb(226 232 240) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-meta-label { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +${scopeSelector} .org-meta-value { + font-size: 1rem; + font-weight: 600; + color: var(--primary-hover); +} + +${scopeSelector} .org-metric-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-hero-grid, + ${scopeSelector} .org-meta-row, + ${scopeSelector} .org-metric-grid { + grid-template-columns: 1fr; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.OverviewCard = function OverviewCard() { + const MetricCard = OrgPortal.componentFns.MetricCard; + const PanelCard = window.SharedUI.componentFns.PanelCard; + const readiness = getters.getAssetReadiness(); + const headquarters = portalData.org.headquarters || "ArmA Verse"; + const assetCount = store.getAssets().length; + const fleetCount = store.getFleet().length; + const funds = store.getFunds(); + const memberCount = store.getMembers().length; + const reputation = store.getReputation(); + ensureScopedStyle("portal-overview-card", overviewCardCss); + + return PanelCard({ + className: "org-span-12", + eyebrow: portalData.org.tag, + title: "Organization Overview", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-hero-grid" }, + h( + "div", + { className: "org-hero-copy" }, + h( + "p", + { className: "org-summary" }, + portalData.org.type, + " operating from ", + headquarters, + ". Treasury, fleet status, inventory, and roster management are surfaced here first.", + ), + h( + "div", + { className: "org-meta-row" }, + h( + "div", + { className: "org-meta-item" }, + h( + "span", + { className: "org-meta-label" }, + "Director", + ), + h( + "span", + { className: "org-meta-value" }, + getters.formatDisplayName(portalData.org.owner), + ), + ), + h( + "div", + { className: "org-meta-item" }, + h( + "span", + { className: "org-meta-label" }, + "Active Members", + ), + h( + "span", + { className: "org-meta-value" }, + `${memberCount} total`, + ), + ), + h( + "div", + { className: "org-meta-item" }, + h( + "span", + { className: "org-meta-label" }, + "Fleet Readiness", + ), + h( + "span", + { className: "org-meta-value" }, + readiness === null ? "N/A" : `${readiness}%`, + ), + ), + ), + ), + h( + "div", + { className: "org-metric-grid" }, + MetricCard( + "Org Funds", + getters.formatCurrency(funds), + "Organization treasury balance", + ), + MetricCard( + "Reputation", + reputation, + "Organization standing", + ), + MetricCard( + "Asset Lines", + assetCount, + "Tracked supply and equipment entries", + ), + MetricCard( + "Fleet Vehicles", + fleetCount, + "Tracked air, ground, and naval vehicles", + ), + ), + ), + }); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/portal/simpleStat.js b/arma/client/addons/org/ui/src/components/portal/simpleStat.js new file mode 100644 index 0000000..eceb6f6 --- /dev/null +++ b/arma/client/addons/org/ui/src/components/portal/simpleStat.js @@ -0,0 +1,39 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const scopeAttr = "data-ui-simple-stat"; + const scopeSelector = `[${scopeAttr}]`; + const simpleStatCss = ` +${scopeSelector} { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 90px; +} + +${scopeSelector} .org-simple-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +${scopeSelector} .org-simple-value { + font-size: 0.95rem; + color: var(--text-main); +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.SimpleStat = function SimpleStat(label, value) { + ensureScopedStyle("portal-simple-stat", simpleStatCss); + + return h( + "div", + { className: "org-simple-stat", [scopeAttr]: "" }, + h("span", { className: "org-simple-label" }, label), + h("strong", { className: "org-simple-value" }, value), + ); + }; +})(); diff --git a/arma/client/addons/org/ui/src/components/portal/treasuryCard.js b/arma/client/addons/org/ui/src/components/portal/treasuryCard.js new file mode 100644 index 0000000..0de5144 --- /dev/null +++ b/arma/client/addons/org/ui/src/components/portal/treasuryCard.js @@ -0,0 +1,443 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle, createSignal } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const store = OrgPortal.store; + const getters = OrgPortal.getters; + const actions = OrgPortal.actions; + const scopeAttr = "data-ui-treasury-card"; + const scopeSelector = `[${scopeAttr}]`; + const [getTreasuryTab, setTreasuryTab] = createSignal("overview"); + const [getTreasuryMenuOpen, setTreasuryMenuOpen] = createSignal(false); + const treasuryCardCss = ` +${scopeSelector} .org-treasury-menu { + position: relative; +} + +${scopeSelector} .org-menu-btn { + width: 2.75rem; + height: 2.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid var(--border); + background: #f8fafc; + color: var(--text-muted); +} + +${scopeSelector} .org-menu-btn:hover { + color: var(--primary-hover); + border-color: rgb(148 163 184 / 0.65); +} + +${scopeSelector} .org-menu-btn svg { + width: 1.1rem; + height: 1.1rem; +} + +${scopeSelector} .org-menu-dropdown { + position: absolute; + top: calc(100% + 0.6rem); + right: 0; + min-width: 10.5rem; + padding: 0.45rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fff; + box-shadow: 0 12px 28px rgb(15 23 42 / 0.12); + display: flex; + flex-direction: column; + gap: 0.35rem; + z-index: 5; +} + +${scopeSelector} .org-menu-option + .org-menu-option { + margin-left: 0; +} + +${scopeSelector} .org-menu-option { + width: 100%; + justify-content: flex-start; + background: transparent; + color: var(--text-main); + border: 1px solid transparent; +} + +${scopeSelector} .org-menu-option:hover { + background: #f8fafc; + border-color: rgb(148 163 184 / 0.35); +} + +${scopeSelector} .org-menu-option.is-active { + background: rgb(226 232 240 / 0.7); + color: var(--primary-hover); + border-color: rgb(148 163 184 / 0.35); +} + +${scopeSelector} .org-finance-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +${scopeSelector} .org-finance-meta > div { + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +${scopeSelector} .org-meta-label { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +${scopeSelector} .org-action-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; +} + +${scopeSelector} .org-action-grid button + button { + margin-left: 0; +} + +${scopeSelector} .org-action-grid button { + width: 100%; +} + +${scopeSelector} .org-access-note { + margin: 0 0 1rem; + color: var(--text-muted); + font-size: 0.95rem; +} + +${scopeSelector} .org-credit-summary { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.85rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-credit-summary strong { + font-size: 1rem; +} + +${scopeSelector} .org-credit-summary span:last-child { + font-size: 0.92rem; + line-height: 1.45; +} + +${scopeSelector} .org-credit-lines-list { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +${scopeSelector} .org-treasury-body { + display: flex; + flex: 1; + flex-direction: column; + gap: 1rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-credit-line-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-credit-line-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-credit-line-member { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +${scopeSelector} .org-credit-line-label { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +${scopeSelector} .org-credit-line-empty { + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; + color: var(--text-muted); +} + +@media (max-width: 960px) { + ${scopeSelector} .org-finance-meta { + grid-template-columns: 1fr; + } + + ${scopeSelector} .org-credit-line-row { + flex-direction: column; + align-items: flex-start; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.TreasuryCard = function TreasuryCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + const creditLines = store.getCreditLines(); + const reputation = store.getReputation(); + const allowTreasuryActions = getters.canManageTreasury(); + const activeTab = getTreasuryTab(); + const isMenuOpen = getTreasuryMenuOpen(); + const activeCreditLabel = + creditLines.length === 1 + ? "1 active credit line" + : `${creditLines.length} active credit lines`; + ensureScopedStyle("portal-treasury-card", treasuryCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-5", + title: "Treasury", + subtitle: "Organization funds, reputation and payouts.", + headerExtras: h( + "div", + { className: "org-treasury-menu" }, + h( + "button", + { + type: "button", + className: "org-menu-btn", + title: "Treasury views", + "aria-label": "Treasury views", + onClick: () => setTreasuryMenuOpen((open) => !open), + }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "aria-hidden": "true", + }, + h("line", { x1: "4", y1: "7", x2: "20", y2: "7" }), + h("line", { x1: "4", y1: "12", x2: "20", y2: "12" }), + h("line", { x1: "4", y1: "17", x2: "20", y2: "17" }), + ), + ), + isMenuOpen + ? h( + "div", + { className: "org-menu-dropdown" }, + h( + "button", + { + type: "button", + className: + activeTab === "overview" + ? "org-menu-option is-active" + : "org-menu-option", + onClick: () => { + setTreasuryTab("overview"); + setTreasuryMenuOpen(false); + }, + }, + "Overview", + ), + h( + "button", + { + type: "button", + className: + activeTab === "credit" + ? "org-menu-option is-active" + : "org-menu-option", + onClick: () => { + setTreasuryTab("credit"); + setTreasuryMenuOpen(false); + }, + }, + "Credit Lines", + ), + ) + : null, + ), + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-treasury-body" }, + activeTab === "credit" + ? creditLines.length > 0 + ? h( + "div", + { className: "org-credit-lines-list" }, + ...creditLines.map((line) => + h( + "article", + { className: "org-credit-line-row" }, + h( + "div", + { + className: + "org-credit-line-member", + }, + h( + "span", + { + className: + "org-credit-line-label", + }, + "Member", + ), + h("strong", null, line.member), + ), + h( + "div", + { + className: + "org-credit-line-member", + }, + h( + "span", + { + className: + "org-credit-line-label", + }, + "Amount", + ), + h( + "strong", + null, + getters.formatCurrency( + line.amount, + ), + ), + ), + ), + ), + ) + : h( + "div", + { className: "org-credit-line-empty" }, + "No active credit lines.", + ) + : h( + "div", + null, + h( + "div", + { className: "org-finance-meta" }, + h( + "div", + null, + h( + "span", + { className: "org-meta-label" }, + "Funds", + ), + h( + "strong", + null, + getters.formatCurrency(store.getFunds()), + ), + ), + h( + "div", + null, + h( + "span", + { className: "org-meta-label" }, + "Reputation", + ), + h("strong", null, `${reputation}`), + ), + ), + allowTreasuryActions + ? h( + "div", + { className: "org-action-grid" }, + h( + "button", + { + type: "button", + onClick: () => + actions.openModal("payroll"), + }, + "Run Payroll", + ), + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => + actions.openModal("transfer"), + }, + "Send Funds", + ), + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => + actions.openModal("credit"), + }, + "Credit Line", + ), + ) + : h( + "p", + { className: "org-access-note" }, + "Only the organization leader or CEO can manage treasury actions.", + ), + h( + "div", + { className: "org-credit-summary" }, + h( + "span", + { className: "org-meta-label" }, + "Credit Line Status", + ), + h("strong", null, activeCreditLabel), + h( + "span", + null, + creditLines.length > 0 + ? "Open the Credit Lines tab to review assigned members and amounts." + : "Assign a credit line to create the first approved member limit.", + ), + ), + ), + ), + }); + }; +})(); diff --git a/arma/client/addons/org/ui/src/portal/actions.js b/arma/client/addons/org/ui/src/portal/actions.js new file mode 100644 index 0000000..d27e99c --- /dev/null +++ b/arma/client/addons/org/ui/src/portal/actions.js @@ -0,0 +1,300 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { portalData } = OrgPortal.data; + const store = OrgPortal.store; + const getters = OrgPortal.getters; + const registryStore = window.RegistryApp.store; + + class OrgPortalActions { + constructor() { + this.treasuryNoticeTimer = null; + } + + showTreasuryNotice(type, text) { + store.setTreasuryNotice({ type, text }); + + if (this.treasuryNoticeTimer) { + clearTimeout(this.treasuryNoticeTimer); + } + + this.treasuryNoticeTimer = setTimeout(() => { + store.setTreasuryNotice({ type: "", text: "" }); + this.treasuryNoticeTimer = null; + }, 3500); + } + + parseAmount(value) { + const amount = Number(value); + return Number.isFinite(amount) ? Math.round(amount) : 0; + } + + getInputValue(id) { + const el = document.getElementById(id); + return el ? el.value : ""; + } + + closePortal() { + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (bridge && typeof bridge.close === "function") { + bridge.close({}); + return; + } + + if (registryStore) { + registryStore.setView("home"); + } + } + + openModal(type) { + if ( + (type === "payroll" || + type === "transfer" || + type === "credit") && + !getters.canManageTreasury() + ) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return; + } + + if (type === "disband" && !getters.canDisbandOrg()) { + return; + } + + if (type === "leave" && !getters.canLeaveOrg()) { + return; + } + + store.setModal({ type }); + } + + closeModal() { + store.setModal(null); + } + + removeMember(member) { + if (!getters.canManageMembers()) { + return false; + } + + if (getters.isProtectedMember(member)) { + return false; + } + + const memberUid = getters.getMemberUid(member); + const memberName = getters.getMemberName(member); + + store.setMembers((currentMembers) => + currentMembers.filter((entry) => + memberUid + ? entry.uid !== memberUid + : entry.name !== memberName, + ), + ); + store.setCreditLines((currentLines) => + currentLines.filter((line) => + memberUid + ? line.uid !== memberUid + : line.member !== memberName, + ), + ); + return true; + } + + disbandOrganization() { + if (!getters.canDisbandOrg()) { + return false; + } + + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (!bridge || typeof bridge.requestDisbandOrg !== "function") { + this.showTreasuryNotice( + "error", + "Disband bridge is unavailable.", + ); + return false; + } + + this.closeModal(); + bridge.requestDisbandOrg(); + return true; + } + + leaveOrganization() { + if (!getters.canLeaveOrg()) { + return false; + } + + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (!bridge || typeof bridge.requestLeaveOrg !== "function") { + this.showTreasuryNotice( + "error", + "Leave bridge is unavailable.", + ); + return false; + } + + this.closeModal(); + bridge.requestLeaveOrg(); + return true; + } + + runPayroll(amountPerMember) { + if (!getters.canManageTreasury()) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return false; + } + + const members = store.getMembers(); + const funds = store.getFunds(); + + if (members.length === 0) { + this.showTreasuryNotice( + "error", + "No members available for payroll.", + ); + return false; + } + + if (amountPerMember <= 0) { + this.showTreasuryNotice( + "error", + "Enter a valid payroll amount.", + ); + return false; + } + + const total = amountPerMember * members.length; + if (total > funds) { + this.showTreasuryNotice( + "error", + "Insufficient org funds for payroll.", + ); + return false; + } + + store.setFunds(funds - total); + this.showTreasuryNotice( + "success", + `Payroll sent to ${members.length} members for ${getters.formatCurrency(total)}.`, + ); + return true; + } + + sendFundsToMember(memberName, amount) { + if (!getters.canManageTreasury()) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return false; + } + + const funds = store.getFunds(); + + if (!memberName) { + this.showTreasuryNotice( + "error", + "Select a member to receive funds.", + ); + return false; + } + + if (amount <= 0) { + this.showTreasuryNotice( + "error", + "Enter a valid transfer amount.", + ); + return false; + } + + if (amount > funds) { + this.showTreasuryNotice( + "error", + "Insufficient org funds for this transfer.", + ); + return false; + } + + store.setFunds(funds - amount); + this.showTreasuryNotice( + "success", + `${getters.formatCurrency(amount)} sent to ${memberName}.`, + ); + return true; + } + + grantCreditLine(memberUid, amount) { + if (!getters.canManageTreasury()) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return false; + } + + if (!memberUid) { + this.showTreasuryNotice( + "error", + "Select a member for the credit line.", + ); + return false; + } + + if (amount <= 0) { + this.showTreasuryNotice( + "error", + "Enter a valid credit line amount.", + ); + return false; + } + + const member = store + .getMembers() + .find((entry) => getters.getMemberUid(entry) === memberUid); + const memberName = member ? getters.getMemberName(member) : ""; + + if (!memberName) { + this.showTreasuryNotice( + "error", + "Selected member was not found in the organization roster.", + ); + return false; + } + + const bridge = window.RegistryApp + ? window.RegistryApp.bridge + : null; + + if (!bridge || typeof bridge.requestCreditLine !== "function") { + this.showTreasuryNotice( + "error", + "Credit line bridge is unavailable.", + ); + return false; + } + + return bridge.requestCreditLine({ + memberUid, + memberName, + amount, + }); + } + } + + OrgPortal.actions = new OrgPortalActions(); +})(); diff --git a/arma/client/addons/org/ui/src/portal/data.js b/arma/client/addons/org/ui/src/portal/data.js new file mode 100644 index 0000000..72b4c3b --- /dev/null +++ b/arma/client/addons/org/ui/src/portal/data.js @@ -0,0 +1,107 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const staticOrgProfile = { + type: "Organization", + status: "Operational", + headquarters: "ArmA Verse", + }; + + function cloneValue(value) { + return JSON.parse(JSON.stringify(value)); + } + + function replaceObject(target, source) { + Object.keys(target).forEach((key) => delete target[key]); + Object.assign(target, cloneValue(source)); + } + + function replaceArray(target, source) { + target.splice(0, target.length, ...cloneValue(source)); + } + + OrgPortal.data = { + portalData: { + org: Object.assign( + { + name: "", + tag: "", + owner: "", + ownerUid: "", + isDefault: false, + }, + staticOrgProfile, + ), + funds: 0, + reputation: 0, + creditLines: [], + members: [], + fleet: [], + assets: [], + activity: [], + roadmap: [ + { + name: "Contracts Board", + status: "Planned", + detail: "Track payouts, assignments, and claim approvals.", + }, + { + name: "Diplomacy", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + { + name: "Logistics Queue", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + { + name: "Permissions", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + ], + }, + session: { + actorName: "", + actorUid: "", + role: "", + ceo: false, + }, + applyLoginPayload(payload) { + replaceObject( + this.portalData.org, + Object.assign( + {}, + payload.portalData.org || {}, + staticOrgProfile, + ), + ); + this.portalData.funds = payload.portalData.funds || 0; + this.portalData.reputation = payload.portalData.reputation || 0; + replaceArray( + this.portalData.creditLines, + payload.portalData.creditLines || [], + ); + + replaceArray( + this.portalData.members, + payload.portalData.members || [], + ); + replaceArray(this.portalData.fleet, payload.portalData.fleet || []); + replaceArray( + this.portalData.assets, + payload.portalData.assets || [], + ); + replaceArray( + this.portalData.activity, + payload.portalData.activity || [], + ); + replaceArray( + this.portalData.roadmap, + payload.portalData.roadmap || [], + ); + + replaceObject(this.session, payload.session || {}); + }, + }; +})(); diff --git a/arma/client/addons/org/ui/src/portal/getters.js b/arma/client/addons/org/ui/src/portal/getters.js new file mode 100644 index 0000000..534c93c --- /dev/null +++ b/arma/client/addons/org/ui/src/portal/getters.js @@ -0,0 +1,178 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { portalData, session } = OrgPortal.data; + + class OrgPortalGetters { + formatCurrency(value) { + return "$" + Number(value || 0).toLocaleString(); + } + + formatVehicleType(type) { + if (!type) { + return ""; + } + + return type.charAt(0).toUpperCase() + type.slice(1); + } + + formatAssetType(type) { + if (!type) { + return ""; + } + + return type.charAt(0).toUpperCase() + type.slice(1); + } + + formatDisplayName(value) { + if (!value) { + return ""; + } + + return String(value) + .trim() + .split(/\s+/) + .map((part) => { + if (!part) { + return ""; + } + + return ( + part.charAt(0).toUpperCase() + + part.slice(1).toLowerCase() + ); + }) + .join(" "); + } + + getAssetReadiness() { + const fleet = OrgPortal.store + ? OrgPortal.store.getFleet() + : portalData.fleet; + if (fleet.length === 0) { + return null; + } + + const total = fleet.reduce( + (sum, unit) => sum + (100 - parseInt(unit.damage, 10)), + 0, + ); + return Math.round(total / fleet.length); + } + + getNormalizedRole() { + return String(session.role || "") + .trim() + .toUpperCase(); + } + + isDefaultOrg() { + return ( + portalData.org.isDefault === true || + String(portalData.org.tag || "") + .trim() + .toUpperCase() === "DEFAULT" + ); + } + + isOrgOwner() { + const ownerUid = String( + portalData.org.ownerUid || portalData.org.owner || "", + ) + .trim() + .toLowerCase(); + const actorUid = String(session.actorUid || "") + .trim() + .toLowerCase(); + + if (ownerUid && actorUid) { + return actorUid === ownerUid; + } + + return ( + String(session.actorName || "") + .trim() + .toLowerCase() === + String(portalData.org.owner || "") + .trim() + .toLowerCase() + ); + } + + isSessionCeo() { + return session.ceo === true; + } + + isOrgLeaderOrCeo() { + return ( + this.isOrgOwner() || + this.getNormalizedRole() === "LEADER" || + (this.isDefaultOrg() && this.isSessionCeo()) + ); + } + + canManageMembers() { + return this.isOrgLeaderOrCeo(); + } + + canManageTreasury() { + return this.isOrgLeaderOrCeo(); + } + + canDisbandOrg() { + return this.isOrgOwner() && !this.isDefaultOrg(); + } + + canLeaveOrg() { + return !this.isDefaultOrg() && !this.isOrgOwner(); + } + + getMemberName(member) { + if (member && typeof member === "object") { + return String(member.name || ""); + } + + return String(member || ""); + } + + getMemberUid(member) { + if (member && typeof member === "object") { + return String(member.uid || ""); + } + + return ""; + } + + isOwnerMember(member) { + return ( + this.getMemberName(member).trim().toLowerCase() === + String(portalData.org.owner || "") + .trim() + .toLowerCase() + ); + } + + isCurrentMember(member) { + const memberUid = this.getMemberUid(member).trim().toLowerCase(); + const actorUid = String(session.actorUid || "") + .trim() + .toLowerCase(); + + if (memberUid && actorUid) { + return memberUid === actorUid; + } + + return ( + this.getMemberName(member).trim().toLowerCase() === + String(session.actorName || "") + .trim() + .toLowerCase() + ); + } + + isProtectedMember(member) { + return this.isOwnerMember(member) || this.isCurrentMember(member); + } + } + + OrgPortal.getters = new OrgPortalGetters(); +})(); diff --git a/arma/client/addons/org/ui/src/portal/store.js b/arma/client/addons/org/ui/src/portal/store.js new file mode 100644 index 0000000..d17a366 --- /dev/null +++ b/arma/client/addons/org/ui/src/portal/store.js @@ -0,0 +1,49 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { createSignal } = window.RegistryApp.runtime; + const { portalData } = OrgPortal.data; + + class OrgPortalStore { + constructor() { + [this.getFunds, this.setFunds] = createSignal(portalData.funds); + [this.getReputation, this.setReputation] = createSignal( + portalData.reputation, + ); + [this.getMembers, this.setMembers] = createSignal([ + ...portalData.members, + ]); + [this.getCreditLines, this.setCreditLines] = createSignal([ + ...portalData.creditLines, + ]); + [this.getFleet, this.setFleet] = createSignal([ + ...portalData.fleet, + ]); + [this.getAssets, this.setAssets] = createSignal([ + ...portalData.assets, + ]); + [this.getActivity, this.setActivity] = createSignal([ + ...portalData.activity, + ]); + [this.getTreasuryNotice, this.setTreasuryNotice] = createSignal({ + type: "", + text: "", + }); + [this.getModal, this.setModal] = createSignal(null); + [this.getOrgDisbanded, this.setOrgDisbanded] = createSignal(false); + } + + hydrateFromPayload(payload) { + const nextPortalData = payload.portalData || {}; + + this.setFunds(nextPortalData.funds || 0); + this.setReputation(nextPortalData.reputation || 0); + this.setMembers([...(nextPortalData.members || [])]); + this.setCreditLines([...(nextPortalData.creditLines || [])]); + this.setFleet([...(nextPortalData.fleet || [])]); + this.setAssets([...(nextPortalData.assets || [])]); + this.setActivity([...(nextPortalData.activity || [])]); + } + } + + OrgPortal.store = new OrgPortalStore(); +})(); diff --git a/arma/client/addons/org/ui/src/registry/store.js b/arma/client/addons/org/ui/src/registry/store.js new file mode 100644 index 0000000..131ceba --- /dev/null +++ b/arma/client/addons/org/ui/src/registry/store.js @@ -0,0 +1,91 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { createSignal } = RegistryApp.runtime; + + class RegistryStore { + constructor() { + [this.getView, this.setView] = createSignal("home"); + [this.getIsAuthenticating, this.setIsAuthenticating] = + createSignal(false); + [this.getLoginError, this.setLoginError] = createSignal(""); + [this.getIsCreating, this.setIsCreating] = createSignal(false); + [this.getCreateError, this.setCreateError] = createSignal(""); + } + + startLogin() { + this.setLoginError(""); + this.setIsAuthenticating(true); + } + + startCreate() { + this.setCreateError(""); + this.setIsCreating(true); + } + + failLogin(message) { + this.setIsAuthenticating(false); + this.setLoginError(message || "Authentication failed."); + } + + failCreate(message) { + this.setIsCreating(false); + this.setCreateError(message || "Organization registration failed."); + } + + hydratePortal(payload) { + const portalApi = + window.OrgPortal && window.OrgPortal.data + ? window.OrgPortal.data + : null; + const portalStore = + window.OrgPortal && window.OrgPortal.store + ? window.OrgPortal.store + : null; + const portalData = + payload && payload.portalData ? payload.portalData : null; + const sessionData = + payload && payload.session ? payload.session : null; + + if ( + !portalApi || + typeof portalApi.applyLoginPayload !== "function" || + !portalStore || + typeof portalStore.hydrateFromPayload !== "function" || + !portalData || + !sessionData + ) { + return false; + } + + portalApi.applyLoginPayload(payload); + portalStore.hydrateFromPayload(payload); + return true; + } + + completeLogin(payload) { + if (!this.hydratePortal(payload)) { + this.failLogin("Login response was missing portal data."); + return; + } + + this.setLoginError(""); + this.setIsAuthenticating(false); + this.setView("portal"); + } + + completeCreate(payload) { + if (!this.hydratePortal(payload)) { + this.failCreate( + "Organization registration response was missing portal data.", + ); + return; + } + + this.setCreateError(""); + this.setIsCreating(false); + this.setView("portal"); + } + } + + RegistryApp.store = new RegistryStore(); +})(); diff --git a/arma/client/addons/org/ui/src/runtime.js b/arma/client/addons/org/ui/src/runtime.js new file mode 100644 index 0000000..da7fc91 --- /dev/null +++ b/arma/client/addons/org/ui/src/runtime.js @@ -0,0 +1,9 @@ +(function () { + const runtime = window.ForgeWebUI; + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + + RegistryApp.runtime = runtime; + OrgPortal.runtime = runtime; + window.AppRuntime = runtime; +})(); diff --git a/arma/client/addons/org/ui/src/styles.css b/arma/client/addons/org/ui/src/styles.css new file mode 100644 index 0000000..e4677c8 --- /dev/null +++ b/arma/client/addons/org/ui/src/styles.css @@ -0,0 +1,280 @@ +:root { + --bg-app: #fdfcf8; + --bg-surface: #ffffff; + --bg-surface-hover: #f1f5f9; + --primary: #475569; + --primary-hover: #1e293b; + --text-main: #1f2937; + --text-muted: #64748b; + --text-inverse: #f8fafc; + --border: #e2e8f0; + --radius: 8px; + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --footer-bg: #1e293b; +} + +html, +body { + height: 100%; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + font-family: + "Inter", + system-ui, + -apple-system, + sans-serif; + margin: 0; + padding: 0; + background: var(--bg-app); + color: var(--text-main); + line-height: 1.6; + overflow: hidden; +} + +#app { + height: 100vh; + overflow: hidden; +} + +.app-shell { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +#org-portal-frame-root { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +main { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; +} + +.container { + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 2rem; + flex: 1; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.header { + text-align: center; + margin-bottom: 3rem; + padding-bottom: 2rem; + border-bottom: 1px solid var(--border); + + h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 0.5rem; + letter-spacing: -0.025em; + color: var(--primary-hover); + } + + p { + color: var(--text-muted); + font-size: 1.1rem; + } +} + +.card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + box-shadow: var(--shadow); + text-align: center; + + h2 { + margin-top: 0; + font-size: 1.8rem; + color: var(--primary-hover); + } +} + +button { + background: var(--primary); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: var(--radius); + cursor: pointer; + font-size: 1rem; + font-weight: 500; + transition: all 0.2s ease; + + &:hover { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.65; + transform: none; + box-shadow: none; + } + + & + & { + margin-left: 1rem; + } +} + +.footer { + margin-top: auto; + background: var(--footer-bg); + color: var(--text-inverse); + display: block; + + .wrapper { + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 3rem 2rem; + box-sizing: border-box; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4rem; + } + + h3 { + color: var(--text-inverse); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 700; + margin-bottom: 1.5rem; + border-bottom: 1px solid #475569; + padding-bottom: 0.5rem; + margin-right: 1rem; + } + + ul { + li { + color: #cbd5e1; + font-size: 0.95rem; + margin-bottom: 0.75rem; + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: white; + } + } + } +} + +.org-secondary-btn { + background: var(--bg-surface); + color: var(--text-main); + border: 1px solid var(--border); + + &:hover { + background: var(--bg-surface-hover); + color: var(--text-main); + } +} + +.org-danger-btn { + background: #7f1d1d; + color: #fef2f2; + + &:hover { + background: #991b1b; + } +} + +.org-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + padding: 0; +} + +.org-icon { + width: 1rem; + height: 1rem; +} + +.org-page-header { + text-align: left; + margin-bottom: 0; +} + +.org-page-heading { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.org-page-kicker { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 600; +} + +.org-page-title { + margin: 0; +} + +.org-page-subtitle { + font-size: 0.9rem; + color: var(--text-muted); + margin: 0; +} + +.org-page-meta { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +@media (max-width: 960px) { + .container { + padding: 1.5rem; + } + + .header { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + + h1 { + font-size: 2rem; + } + } + + .footer .wrapper { + grid-template-columns: 1fr; + } + + .org-page-heading { + gap: 0.3rem; + } +} diff --git a/arma/client/addons/org/ui/src/views/DisbandedView.js b/arma/client/addons/org/ui/src/views/DisbandedView.js new file mode 100644 index 0000000..01659e0 --- /dev/null +++ b/arma/client/addons/org/ui/src/views/DisbandedView.js @@ -0,0 +1,36 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const registryStore = window.RegistryApp.store; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.DisbandedView = function DisbandedView() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + + return PanelCard({ + className: "org-span-12 org-empty-state", + eyebrow: "Organization Removed", + title: portalData.org.name, + body: h( + "div", + null, + h( + "p", + { className: "org-summary" }, + "This organization has been disbanded. Member access, assets, and fleet management are no longer available from this portal preview.", + ), + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => registryStore.setView("home"), + }, + "Return to Registry", + ), + ), + }); + }; +})(); diff --git a/arma/client/addons/org/ui/src/views/HomeView.js b/arma/client/addons/org/ui/src/views/HomeView.js new file mode 100644 index 0000000..69f259e --- /dev/null +++ b/arma/client/addons/org/ui/src/views/HomeView.js @@ -0,0 +1,89 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const store = RegistryApp.store; + const bridge = RegistryApp.bridge; + const scopeAttr = "data-ui-home-view"; + const scopeSelector = `[${scopeAttr}]`; + const homeViewCss = ` +${scopeSelector} { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin-bottom: 2rem; +} + +${scopeSelector} .home-feedback { + padding: 0.85rem 1rem; + border-radius: var(--radius); + font-size: 0.92rem; + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; +} + +@media (max-width: 960px) { + ${scopeSelector} { + grid-template-columns: 1fr; + } +} +`; + + RegistryApp.componentFns = RegistryApp.componentFns || {}; + + RegistryApp.componentFns.HomeView = function HomeView() { + const isAuthenticating = store.getIsAuthenticating(); + const loginError = store.getLoginError(); + ensureScopedStyle("main-home-view", homeViewCss); + + return h( + "div", + { className: "content", [scopeAttr]: "" }, + h( + "div", + { className: "card" }, + h("h2", null, "Create Organization"), + h( + "p", + null, + "Establish your Task Force, PMC, or Milsim unit with the Global Organization Network. Receive your official unit designator and TO&E authorization instantly.", + ), + h( + "button", + { onClick: () => store.setView("create") }, + "Register", + ), + ), + h( + "div", + { className: "card" }, + h("h2", null, "Organization Portal"), + h( + "p", + null, + "Access your unit dashboard to modify rosters, adjust active deployments, and submit after-action reports through the secure field uplink.", + ), + loginError + ? h("div", { className: "home-feedback" }, loginError) + : null, + h( + "button", + { + disabled: isAuthenticating, + onClick: () => { + if (!bridge) { + store.failLogin( + "Login bridge is not available.", + ); + return; + } + + bridge.requestLogin({}); + }, + }, + isAuthenticating ? "Opening Portal..." : "Login", + ), + ), + ); + }; +})(); diff --git a/arma/client/addons/org/ui/src/views/PortalView.js b/arma/client/addons/org/ui/src/views/PortalView.js new file mode 100644 index 0000000..989fa71 --- /dev/null +++ b/arma/client/addons/org/ui/src/views/PortalView.js @@ -0,0 +1,247 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData, session } = OrgPortal.data; + const store = OrgPortal.store; + const portalViewScope = "[data-ui-portal-view]"; + + ensureScopedStyle( + "portal-view", + ` + ${portalViewScope} { + --org-row-card-max-height: 36rem; + } + + ${portalViewScope} .org-toast-stack { + position: fixed; + top: 1.5rem; + right: 2rem; + z-index: 20; + display: flex; + flex-direction: column; + gap: 0.75rem; + pointer-events: none; + } + + ${portalViewScope} .org-toast { + max-width: 24rem; + padding: 0.9rem 1rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: #fff; + box-shadow: 0 12px 28px rgb(15 23 42 / 0.14); + font-size: 0.92rem; + pointer-events: auto; + } + + ${portalViewScope} .org-toast.is-success { + background: #ecfdf5; + border-color: #bbf7d0; + color: #166534; + } + + ${portalViewScope} .org-toast.is-error { + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; + } + + ${portalViewScope} .org-dashboard-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 1.5rem; + align-items: stretch; + } + + ${portalViewScope} .org-panel { + margin-bottom: 0; + text-align: left; + } + + ${portalViewScope} .org-scroll-panel { + display: flex; + flex-direction: column; + min-height: 0; + max-height: var(--org-row-card-max-height); + overflow: hidden; + } + + ${portalViewScope} .org-island-root { + display: flex; + align-self: stretch; + min-height: 0; + min-width: 0; + } + + ${portalViewScope} .org-island-root > .org-panel { + height: 100%; + width: 100%; + } + + ${portalViewScope} .org-span-12 { + grid-column: span 12; + } + + ${portalViewScope} .org-span-7 { + grid-column: span 7; + } + + ${portalViewScope} .org-span-6 { + grid-column: span 6; + } + + ${portalViewScope} .org-span-5 { + grid-column: span 5; + } + + @media (max-width: 960px) { + ${portalViewScope} .org-toast-stack { + top: 1rem; + right: 1rem; + left: 1rem; + } + + ${portalViewScope} .org-toast { + max-width: none; + } + + ${portalViewScope} .org-span-12, + ${portalViewScope} .org-span-7, + ${portalViewScope} .org-span-6, + ${portalViewScope} .org-span-5 { + grid-column: span 12; + } + + ${portalViewScope} .org-scroll-panel { + max-height: none; + } + + } + `, + ); + + OrgPortal.components = OrgPortal.components || {}; + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.TreasuryNoticeLayer = + function TreasuryNoticeLayer() { + const treasuryNotice = store.getTreasuryNotice(); + if (!treasuryNotice.text) { + return null; + } + + return h( + "div", + { className: "org-toast-stack" }, + h( + "div", + { + className: + treasuryNotice.type === "error" + ? "org-toast is-error" + : "org-toast is-success", + }, + treasuryNotice.text, + ), + ); + }; + + OrgPortal.components.App = function App() { + const Hero = window.SharedUI.componentFns.Hero; + const Footer = window.SharedUI.componentFns.Footer; + const FutureCard = OrgPortal.componentFns.FutureCard; + const DangerCard = OrgPortal.componentFns.DangerCard; + const DisbandedView = OrgPortal.componentFns.DisbandedView; + const footerSections = [ + { + title: "Organization Controls", + items: [ + "Roster Management", + "Fleet Assignment", + "Treasury Permissions", + "Asset Registry", + ], + }, + { + title: "Planned Extensions", + items: [ + "Contracts Board", + "Diplomacy Layer", + "Procurement Queue", + "Reputation History", + ], + }, + ]; + if (store.getOrgDisbanded()) { + return h( + "main", + { "data-ui-portal-view": "" }, + h( + "div", + { className: "container" }, + h( + "div", + { className: "org-dashboard-grid" }, + Hero({ + kicker: portalData.org.tag, + title: portalData.org.name, + subtitle: "Player organization command portal", + meta: `${session.actorName} - ${session.role}`, + }), + DisbandedView(), + ), + ), + h("div", { id: "org-portal-modal-root" }), + Footer({ sections: footerSections }), + ); + } + + return h( + "main", + { "data-ui-portal-view": "" }, + h("div", { id: "org-portal-toast-root" }), + h( + "div", + { className: "container" }, + h( + "div", + { className: "org-dashboard-grid" }, + Hero({ + kicker: portalData.org.tag, + title: portalData.org.name, + subtitle: "Player organization command portal", + meta: `${session.actorName} - ${session.role}`, + }), + h("div", { + className: "org-island-root org-span-12", + id: "org-overview-card-root", + }), + h("div", { + className: "org-island-root org-span-7", + id: "org-fleet-card-root", + }), + h("div", { + className: "org-island-root org-span-5", + id: "org-treasury-card-root", + }), + h("div", { + className: "org-island-root org-span-5", + id: "org-members-card-root", + }), + h("div", { + className: "org-island-root org-span-7", + id: "org-assets-card-root", + }), + h("div", { + className: "org-island-root org-span-6", + id: "org-activity-card-root", + }), + FutureCard(), + DangerCard(), + ), + ), + h("div", { id: "org-portal-modal-root" }), + Footer({ sections: footerSections }), + ); + }; +})(); diff --git a/arma/client/addons/org/ui/src/views/RegistrationView.js b/arma/client/addons/org/ui/src/views/RegistrationView.js new file mode 100644 index 0000000..841abf0 --- /dev/null +++ b/arma/client/addons/org/ui/src/views/RegistrationView.js @@ -0,0 +1,342 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const store = RegistryApp.store; + const bridge = RegistryApp.bridge; + const scopeAttr = "data-ui-registration-view"; + const scopeSelector = `[${scopeAttr}]`; + const registrationViewCss = ` +${scopeSelector} { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + align-items: center; + width: 100%; +} + +${scopeSelector} .info-panel { + text-align: left; + padding: 1rem; +} + +${scopeSelector} .create-feature-list { + text-align: left; + margin-top: 1.5rem; + list-style-type: none; + padding: 0; +} + +${scopeSelector} .create-feature-item { + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +${scopeSelector} .create-feature-icon { + width: 1.2rem; + height: 1.2rem; + flex-shrink: 0; +} + +${scopeSelector} .price-tag { + margin-top: 2rem; + padding: 1rem; + background: var(--bg-app); + border-radius: var(--radius); + border: 1px solid var(--border); +} + +${scopeSelector} .price-label { + display: block; + font-size: 0.9rem; + color: var(--text-muted); +} + +${scopeSelector} .price-value { + display: block; + font-size: 2rem; + font-weight: 700; + color: var(--primary); +} + +${scopeSelector} .form-panel { + margin: 0; +} + +${scopeSelector} .app-form { + display: flex; + flex-direction: column; + gap: 1rem; + text-align: left; +} + +${scopeSelector} .app-form label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-muted); + font-weight: 500; + font-size: 0.9rem; +} + +${scopeSelector} .app-form input, +${scopeSelector} .app-form select { + width: 100%; + padding: 0.75rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-app); + color: var(--text-main); + font-family: inherit; + font-size: 1rem; + box-sizing: border-box; + transition: border-color 0.2s; +} + +${scopeSelector} .app-form input:focus, +${scopeSelector} .app-form select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgb(59 130 246 / 0.1); +} + +${scopeSelector} .form-actions { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; +} + +${scopeSelector} .submit-btn { + width: 100%; +} + +${scopeSelector} .cancel-link { + font-size: 0.9rem; + color: var(--text-muted); + cursor: pointer; + text-decoration: underline; +} + +${scopeSelector} .cancel-link:hover { + color: var(--primary); +} + +${scopeSelector} .form-feedback { + padding: 0.85rem 1rem; + border-radius: var(--radius); + font-size: 0.92rem; +} + +${scopeSelector} .form-feedback.is-error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; +} + +@media (max-width: 960px) { + ${scopeSelector} { + grid-template-columns: 1fr; + } +} +`; + + RegistryApp.componentFns = RegistryApp.componentFns || {}; + + RegistryApp.componentFns.RegistrationView = function RegistrationView() { + const isCreating = store.getIsCreating(); + const createError = store.getCreateError(); + ensureScopedStyle("main-registration-view", registrationViewCss); + + const handleCreate = () => { + const data = { + orgName: String( + document.getElementById("org-create-name")?.value || "", + ).trim(), + type: String( + document.getElementById("org-create-type")?.value || "", + ), + }; + + if (!bridge || typeof bridge.requestCreateOrg !== "function") { + store.failCreate("Registration bridge is not available."); + return; + } + + bridge.requestCreateOrg(data); + }; + + return h( + "div", + { className: "split-container", [scopeAttr]: "" }, + h( + "div", + { className: "info-panel" }, + h("h2", null, "Registration Details"), + h( + "p", + null, + "Complete the form to add your organization to the Global Organization Registry.", + ), + h( + "ul", + { className: "create-feature-list" }, + h( + "li", + { className: "create-feature-item" }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + className: "create-feature-icon", + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Official Organization Designator", + ), + h( + "li", + { className: "create-feature-item" }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + className: "create-feature-icon", + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Secure Comms Channel", + ), + h( + "li", + { className: "create-feature-item" }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + className: "create-feature-icon", + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Deployment Roster Access", + ), + h( + "li", + { className: "create-feature-item" }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + className: "create-feature-icon", + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "After-Action Report Tools", + ), + ), + h( + "div", + { className: "price-tag" }, + h("span", { className: "price-label" }, "Registration Fee"), + h("span", { className: "price-value" }, "$50,000"), + ), + ), + h( + "div", + { className: "form-panel card" }, + h("h2", null, "Organization Registration"), + h( + "div", + { className: "app-form" }, + h( + "div", + null, + h("label", null, "Organization Name"), + h("input", { + id: "org-create-name", + type: "text", + placeholder: "e.g. Task Force 141", + }), + ), + h( + "div", + null, + h("label", null, "Organization Type"), + h( + "select", + { id: "org-create-type" }, + h( + "option", + { value: "infantry" }, + "Infantry / Milsim", + ), + h("option", { value: "aviation" }, "Aviation Wing"), + h( + "option", + { value: "pmc" }, + "Private Military Company", + ), + h( + "option", + { value: "support" }, + "Logistics & Support", + ), + ), + ), + h( + "div", + { className: "form-actions" }, + createError + ? h( + "div", + { className: "form-feedback is-error" }, + createError, + ) + : null, + h( + "button", + { + type: "button", + className: "submit-btn", + disabled: isCreating, + onClick: handleCreate, + }, + isCreating + ? "Submitting Registration..." + : "Submit Registration", + ), + h( + "span", + { + className: "cancel-link", + onClick: () => store.setView("home"), + }, + "Cancel / Return to Main", + ), + ), + ), + ), + ); + }; +})(); diff --git a/arma/client/addons/org/ui/ui.config.mjs b/arma/client/addons/org/ui/ui.config.mjs new file mode 100644 index 0000000..caa5279 --- /dev/null +++ b/arma/client/addons/org/ui/ui.config.mjs @@ -0,0 +1,56 @@ +export default { + addonName: "org", + title: "ORBIS - Global Organization Network", + logLabel: "Org UI", + outputDir: "_site", + jsBundles: [ + { + name: "Org UI app", + output: "org-ui.js", + sources: [ + "src/runtime.js", + "src/registry/store.js", + "src/bridge.js", + "src/portal/data.js", + "src/portal/store.js", + "src/portal/getters.js", + "src/portal/actions.js", + "src/components/navbar.js", + "src/components/header.js", + "src/components/hero.js", + "src/components/footer.js", + "src/components/modal.js", + "src/components/panelCard.js", + "src/components/portal/metricCard.js", + "src/components/portal/simpleStat.js", + "src/components/portal/overviewCard.js", + "src/components/portal/fleetCard.js", + "src/components/portal/treasuryCard.js", + "src/components/portal/assetsCard.js", + "src/components/portal/membersCard.js", + "src/components/portal/activityCard.js", + "src/components/portal/futureCard.js", + "src/components/portal/dangerCard.js", + "src/components/portal/modalLayer.js", + "src/views/DisbandedView.js", + "src/views/PortalView.js", + "src/views/RegistrationView.js", + "src/views/HomeView.js", + "src/components/AppShell.js", + "src/bootstrap.js", + ], + }, + ], + cssBundles: [ + { + name: "Org UI styles", + output: "org-ui.css", + sources: ["src/styles.css"], + }, + ], + site: { + styles: ["org-ui.css"], + commonScripts: ["forge-webui.js"], + scripts: ["org-ui.js"], + }, +}; diff --git a/arma/client/addons/store/$PBOPREFIX$ b/arma/client/addons/store/$PBOPREFIX$ new file mode 100644 index 0000000..b7ca134 --- /dev/null +++ b/arma/client/addons/store/$PBOPREFIX$ @@ -0,0 +1 @@ +forge\forge_client\addons\store diff --git a/arma/client/addons/store/CfgEventHandlers.hpp b/arma/client/addons/store/CfgEventHandlers.hpp new file mode 100644 index 0000000..c6e25db --- /dev/null +++ b/arma/client/addons/store/CfgEventHandlers.hpp @@ -0,0 +1,19 @@ +class Extended_PreStart_EventHandlers { + class ADDON { + init = QUOTE(call COMPILE_SCRIPT(XEH_preStart)); + }; +}; + +class Extended_PreInit_EventHandlers { + class ADDON { + init = QUOTE(call COMPILE_SCRIPT(XEH_preInit)); + clientInit = QUOTE(call COMPILE_SCRIPT(XEH_preInitClient)); + }; +}; + +class Extended_PostInit_EventHandlers { + class ADDON { + init = QUOTE(call COMPILE_SCRIPT(XEH_postInit)); + clientInit = QUOTE(call COMPILE_SCRIPT(XEH_postInitClient)); + }; +}; diff --git a/arma/client/addons/store/README.md b/arma/client/addons/store/README.md new file mode 100644 index 0000000..8a76830 --- /dev/null +++ b/arma/client/addons/store/README.md @@ -0,0 +1,3 @@ +# forge_client_store + +Description for this addon diff --git a/arma/client/addons/store/XEH_PREP.hpp b/arma/client/addons/store/XEH_PREP.hpp new file mode 100644 index 0000000..868066a --- /dev/null +++ b/arma/client/addons/store/XEH_PREP.hpp @@ -0,0 +1,5 @@ +PREP(buildUIPayload); +PREP(handleUIEvents); +PREP(initClass); +PREP(initUIBridge); +PREP(openUI); diff --git a/arma/client/addons/store/XEH_postInit.sqf b/arma/client/addons/store/XEH_postInit.sqf new file mode 100644 index 0000000..421c54b --- /dev/null +++ b/arma/client/addons/store/XEH_postInit.sqf @@ -0,0 +1 @@ +#include "script_component.hpp" diff --git a/arma/client/addons/store/XEH_postInitClient.sqf b/arma/client/addons/store/XEH_postInitClient.sqf new file mode 100644 index 0000000..ac1eb81 --- /dev/null +++ b/arma/client/addons/store/XEH_postInitClient.sqf @@ -0,0 +1,16 @@ +#include "script_component.hpp" + +if (isNil QGVAR(StoreClass)) then { call FUNC(initClass); }; +if (isNil QGVAR(StoreUIBridge)) then { call FUNC(initUIBridge); }; + +[QGVAR(responseCategory), { + params [["_payload", createHashMap, [createHashMap]]]; + + GVAR(StoreUIBridge) call ["handleCategoryResponse", [_payload]]; +}] call CFUNC(addEventHandler); + +[QGVAR(responseCheckout), { + params [["_payload", createHashMap, [createHashMap]]]; + + GVAR(StoreUIBridge) call ["handleCheckoutResponse", [_payload]]; +}] call CFUNC(addEventHandler); diff --git a/arma/client/addons/store/XEH_preInit.sqf b/arma/client/addons/store/XEH_preInit.sqf new file mode 100644 index 0000000..dbef1ae --- /dev/null +++ b/arma/client/addons/store/XEH_preInit.sqf @@ -0,0 +1,10 @@ +#include "script_component.hpp" + +PREP_RECOMPILE_START; +#include "XEH_PREP.hpp" +PREP_RECOMPILE_END; + +// private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)]; + +// #include "initSettings.inc.sqf" +// #include "initKeybinds.inc.sqf" diff --git a/arma/client/addons/store/XEH_preInitClient.sqf b/arma/client/addons/store/XEH_preInitClient.sqf new file mode 100644 index 0000000..421c54b --- /dev/null +++ b/arma/client/addons/store/XEH_preInitClient.sqf @@ -0,0 +1 @@ +#include "script_component.hpp" diff --git a/arma/client/addons/store/XEH_preStart.sqf b/arma/client/addons/store/XEH_preStart.sqf new file mode 100644 index 0000000..a51262a --- /dev/null +++ b/arma/client/addons/store/XEH_preStart.sqf @@ -0,0 +1,2 @@ +#include "script_component.hpp" +#include "XEH_PREP.hpp" diff --git a/arma/client/addons/store/config.cpp b/arma/client/addons/store/config.cpp new file mode 100644 index 0000000..128e9a9 --- /dev/null +++ b/arma/client/addons/store/config.cpp @@ -0,0 +1,22 @@ +#include "script_component.hpp" + +class CfgPatches { + class ADDON { + author = AUTHOR; + authors[] = {"J.Schmidt"}; + url = ECSTRING(main,url); + name = COMPONENT_NAME; + requiredVersion = REQUIRED_VERSION; + requiredAddons[] = { + "forge_client_common", + "forge_client_main" + }; + units[] = {}; + weapons[] = {}; + VERSION_CONFIG; + }; +}; + +#include "CfgEventHandlers.hpp" +#include "ui\RscCommon.hpp" +#include "ui\RscStore.hpp" diff --git a/arma/client/addons/store/functions/fnc_buildUIPayload.sqf b/arma/client/addons/store/functions/fnc_buildUIPayload.sqf new file mode 100644 index 0000000..3b748ce --- /dev/null +++ b/arma/client/addons/store/functions/fnc_buildUIPayload.sqf @@ -0,0 +1,125 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_buildUIPayload.sqf + * Author: IDSolutions + * Date: 2026-03-13 + * Public: No + * + * Description: + * Builds the browser hydrate payload for the store UI from current client state. + * + * Arguments: + * None + * + * Return Value: + * Store UI payload [HASHMAP] + */ + +private _storeState = createHashMap; +private _budget = 50000; +private _creditLine = 0; +private _cashBalance = 0; +private _bankBalance = 0; +private _orgFunds = 0; +private _orgId = ""; +private _orgName = ""; +private _orgOwnerUid = ""; +private _orgCreditLines = createHashMap; +private _playerUid = getPlayerUID player; +private _playerVar = toLowerANSI (vehicleVarName player); +private _isOrgLeader = false; +private _isDefaultOrg = false; +private _isDefaultOrgCeo = false; + +if !(isNil QGVAR(StoreClass)) then { + _storeState = GVAR(StoreClass) call ["getStoreState", []]; + _budget = _storeState getOrDefault ["budget", _budget]; +}; + +if !(isNil QEGVAR(bank,BankClass)) then { + _cashBalance = EGVAR(bank,BankClass) call ["get", ["cash", 0]]; + _bankBalance = EGVAR(bank,BankClass) call ["get", ["bank", 0]]; +}; + +if !(isNil QEGVAR(org,OrgClass)) then { + _orgId = EGVAR(org,OrgClass) call ["get", ["id", ""]]; + _orgName = EGVAR(org,OrgClass) call ["get", ["name", ""]]; + _orgOwnerUid = EGVAR(org,OrgClass) call ["get", ["owner", ""]]; + _orgFunds = EGVAR(org,OrgClass) call ["get", ["funds", 0]]; + _orgCreditLines = EGVAR(org,OrgClass) call ["get", ["credit_lines", createHashMap]]; + _isDefaultOrg = (_orgId isEqualTo "default") || { toLowerANSI _orgOwnerUid isEqualTo "server" }; + _isOrgLeader = _orgOwnerUid isEqualTo _playerUid; + _isDefaultOrgCeo = _isDefaultOrg && { _playerVar isEqualTo "ceo" }; +}; + +if (_orgCreditLines isEqualType createHashMap) then { + private _playerCreditLine = _orgCreditLines getOrDefault [_playerUid, createHashMap]; + if (_playerCreditLine isEqualType createHashMap) then { + _creditLine = _playerCreditLine getOrDefault ["amount", 0]; + }; +}; + +private _canUseOrgFunds = _isOrgLeader || _isDefaultOrgCeo; +private _orgFundsEnabled = _canUseOrgFunds && { _orgFunds > 0 }; +private _paymentSources = [ + createHashMapFromArray [ + ["id", "cash"], + ["label", "Cash"], + ["balance", _cashBalance], + ["enabled", _cashBalance > 0], + ["detail", "Use on-hand cash carried by the player."] + ], + createHashMapFromArray [ + ["id", "bank"], + ["label", "Bank"], + ["balance", _bankBalance], + ["enabled", _bankBalance > 0], + ["detail", "Charge the player bank account."] + ], + createHashMapFromArray [ + ["id", "org_funds"], + ["label", "Org Funds"], + ["balance", _orgFunds], + ["enabled", _orgFundsEnabled], + ["detail", [ + "Only organization leaders or the default-org CEO can use treasury funds.", + [ + "Charge organization treasury funds.", + "No organization funds are currently available." + ] select _orgFundsEnabled + ] select _canUseOrgFunds] + ], + createHashMapFromArray [ + ["id", "credit_line"], + ["label", "Credit Line"], + ["balance", _creditLine], + ["enabled", _creditLine > 0], + ["detail", [ + "No approved credit line is assigned to this member.", + "Use the approved procurement credit line." + ] select (_creditLine > 0)] + ] +]; + +createHashMapFromArray [ + ["session", createHashMapFromArray [ + ["actorName", name player], + ["actorUid", _playerUid], + ["approval", "Field Access"], + ["orgId", _orgId], + ["orgName", _orgName], + ["orgLeader", _isOrgLeader], + ["defaultOrgCeo", _isDefaultOrgCeo], + ["canUseOrgFunds", _canUseOrgFunds] + ]], + ["storeConfig", createHashMapFromArray [ + ["budget", _budget], + ["creditLine", _creditLine], + ["availability", _storeState getOrDefault ["availability", "In-Stock"]], + ["moduleState", _storeState getOrDefault ["moduleState", "Preview"]], + ["paymentSources", _paymentSources], + ["defaultPaymentSource", "cash"] + ]], + ["cartItems", []] +] diff --git a/arma/client/addons/store/functions/fnc_handleUIEvents.sqf b/arma/client/addons/store/functions/fnc_handleUIEvents.sqf new file mode 100644 index 0000000..5ee1dfe --- /dev/null +++ b/arma/client/addons/store/functions/fnc_handleUIEvents.sqf @@ -0,0 +1,41 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_handleUIEvents.sqf + * Author: IDSolutions + * Date: 2026-01-28 + * Last Update: 2026-03-11 + * Public: No + * + * Description: + * Handles the UI events. + * + * Arguments: + * 0: [CONTROL] - The control that triggered the event + * 1: [BOOL] - Whether the event is from a confirm dialog + * 2: [STRING] - The message containing the event data + * + * Return Value: + * UI events handled [BOOL] + * + * Example: + * call forge_client_store_fnc_handleUIEvents; + */ + +params ["_control", "_isConfirmDialog", "_message"]; + +private _alert = fromJSON _message; +private _event = _alert get "event"; +private _data = _alert get "data"; + +diag_log format ["[FORGE:Client:Store] Handling UI event: %1 with data: %2", _event, _data]; + +switch (_event) do { + case "store::close": { closeDialog 1; }; + case "store::ready": { GVAR(StoreUIBridge) call ["handleReady", [_control]]; }; + case "store::category::request": { GVAR(StoreUIBridge) call ["handleCategoryRequest", [_data]]; }; + case "store::checkout::request": { GVAR(StoreUIBridge) call ["handleCheckoutRequest", [_data]]; }; + default { hint format ["Unhandled UI event: %1", _event]; }; +}; + +true; diff --git a/arma/client/addons/store/functions/fnc_initClass.sqf b/arma/client/addons/store/functions/fnc_initClass.sqf new file mode 100644 index 0000000..f88d2ff --- /dev/null +++ b/arma/client/addons/store/functions/fnc_initClass.sqf @@ -0,0 +1,42 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initClass.sqf + * Author: IDSolutions + * Date: 2026-01-28 + * Last Update: 2026-03-12 + * Public: Yes + * + * Description: + * Initializes the store class for managing store data. + * + * Arguments: + * None + * + * Return Value: + * Store class object [HASHMAP OBJECT] + * + * Example: + * call forge_client_store_fnc_initClass + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(StoreBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "StoreBaseClass"], + ["#create", compileFinal { + _self set ["uid", getPlayerUID player]; + _self set ["store", createHashMapFromArray [ + ["budget", 50000], + ["availability", "In-Stock"], + ["moduleState", "Preview"] + ]]; + _self set ["isLoaded", false]; + _self set ["lastSave", time]; + }], + ["getStoreState", compileFinal { + _self getOrDefault ["store", createHashMap] + }] +]; + +GVAR(StoreClass) = createHashMapObject [GVAR(StoreBaseClass)]; +GVAR(StoreClass) diff --git a/arma/client/addons/store/functions/fnc_initUIBridge.sqf b/arma/client/addons/store/functions/fnc_initUIBridge.sqf new file mode 100644 index 0000000..2e707d0 --- /dev/null +++ b/arma/client/addons/store/functions/fnc_initUIBridge.sqf @@ -0,0 +1,119 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initUIBridge.sqf + * Author: IDSolutions + * Date: 2026-03-10 + * Last Update: 2026-03-12 + * Public: No + * + * Description: + * Initializes the store UI bridge for browser control state, event routing, and catalog queries. + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(StoreUIBridgeBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "StoreUIBridgeBaseClass"], + ["getActiveBrowserControl", compileFinal { + private _display = uiNamespace getVariable ["RscStore", displayNull]; + if (isNull _display) exitWith { controlNull }; + + _display displayCtrl 1004 + }], + ["execBridge", compileFinal { + params [["_control", controlNull, [controlNull]], ["_fnName", "", [""]], ["_payload", createHashMap, [createHashMap]]]; + + if (isNull _control || { _fnName isEqualTo "" }) exitWith { false }; + + private _json = toJSON _payload; + _control ctrlWebBrowserAction ["ExecJS", format ["StoreUIBridge.%1(%2)", _fnName, _json]]; + + true + }], + ["sendBridgeEvent", compileFinal { + params [["_event", "", [""]], ["_data", createHashMap, [createHashMap]], ["_control", controlNull, [controlNull]]]; + + if (_event isEqualTo "") exitWith { false }; + + private _targetControl = _control; + if (isNull _targetControl) then { _targetControl = _self call ["getActiveBrowserControl", []]; }; + if (isNull _targetControl) exitWith { false }; + + _self call ["execBridge", [_targetControl, "receive", createHashMapFromArray [ + ["event", _event], + ["data", _data] + ]]] + }], + ["handleReady", compileFinal { + params [["_control", controlNull, [controlNull]]]; + + private _payload = call FUNC(buildUIPayload); + _self call ["sendBridgeEvent", ["store::hydrate", _payload, _control]]; + }], + ["handleCategoryRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _category = toLowerANSI (_data getOrDefault ["category", ""]); + private _uid = getPlayerUID player; + if (_category isEqualTo "") exitWith { + _self call ["sendBridgeEvent", ["store::category::failure", createHashMapFromArray [ + ["message", "No store category was provided."] + ]]]; + }; + + if (_uid isEqualTo "") exitWith { + _self call ["sendBridgeEvent", ["store::category::failure", createHashMapFromArray [ + ["category", _category], + ["message", "Store catalog request is unavailable."] + ]]]; + }; + + diag_log format ["[FORGE:Client:Store] Category request forwarded to server: %1", _category]; + [SRPC(store,requestCategory), [_uid, _category]] call CFUNC(serverEvent); + }], + ["handleCategoryResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _success = _payload getOrDefault ["success", false]; + private _bridgeEvent = ["store::category::failure", "store::category::hydrate"] select _success; + _self call ["sendBridgeEvent", [_bridgeEvent, _payload]]; + }], + ["refreshStoreConfig", compileFinal { + private _payload = call FUNC(buildUIPayload); + _self call ["sendBridgeEvent", ["store::config::hydrate", _payload]]; + }], + ["handleCheckoutRequest", compileFinal { + params [["_data", createHashMap, [createHashMap]]]; + + private _uid = getPlayerUID player; + private _checkoutJson = _data getOrDefault ["checkoutJson", ""]; + + if (_uid isEqualTo "" || { _checkoutJson isEqualTo "" }) exitWith { + _self call ["sendBridgeEvent", ["store::checkout::failure", createHashMapFromArray [ + ["message", "Add at least one supported item before checkout."] + ]]]; + }; + + diag_log format ["[FORGE:Client:Store] Checkout request forwarded to server: %1", _checkoutJson]; + [SRPC(store,requestCheckout), [_uid, _checkoutJson]] call CFUNC(serverEvent); + }], + ["handleCheckoutResponse", compileFinal { + params [["_payload", createHashMap, [createHashMap]]]; + + private _success = _payload getOrDefault ["success", false]; + private _bridgeEvent = ["store::checkout::failure", "store::checkout::success"] select _success; + _self call ["sendBridgeEvent", [_bridgeEvent, _payload]]; + + if (_success) then { + [] spawn { + sleep 0.05; + if !(isNil QGVAR(StoreUIBridge)) then { + GVAR(StoreUIBridge) call ["refreshStoreConfig", []]; + }; + }; + }; + }] +]; + +GVAR(StoreUIBridge) = createHashMapObject [GVAR(StoreUIBridgeBaseClass)]; +GVAR(StoreUIBridge) diff --git a/arma/client/addons/store/functions/fnc_openUI.sqf b/arma/client/addons/store/functions/fnc_openUI.sqf new file mode 100644 index 0000000..e9b4aba --- /dev/null +++ b/arma/client/addons/store/functions/fnc_openUI.sqf @@ -0,0 +1,35 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_openUI.sqf + * Author: IDSolutions + * Date: 2026-01-28 + * Last Update: 2026-03-11 + * Public: No + * + * Description: + * Opens the store interface. + * + * Arguments: + * None + * + * Return Value: + * UI opened [BOOL] + * + * Example: + * call forge_client_store_fnc_openUI; + */ + +private _display = createDialog ["RscStore", true]; +private _ctrl = _display displayCtrl 1004; + +_ctrl ctrlAddEventHandler ["JSDialog", { + params ["_control", "_isConfirmDialog", "_message"]; + + [_control, _isConfirmDialog, _message] call FUNC(handleUIEvents); +}]; + +_ctrl ctrlWebBrowserAction ["LoadFile", QPATHTOF2(ui\_site\index.html)]; +// _ctrl ctrlWebBrowserAction ["OpenDevConsole"]; + +true; diff --git a/arma/client/addons/store/initKeybinds.inc.sqf b/arma/client/addons/store/initKeybinds.inc.sqf new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/arma/client/addons/store/initKeybinds.inc.sqf @@ -0,0 +1 @@ + diff --git a/arma/client/addons/store/initSettings.inc.sqf b/arma/client/addons/store/initSettings.inc.sqf new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/arma/client/addons/store/initSettings.inc.sqf @@ -0,0 +1 @@ + diff --git a/arma/client/addons/store/script_component.hpp b/arma/client/addons/store/script_component.hpp new file mode 100644 index 0000000..5416b30 --- /dev/null +++ b/arma/client/addons/store/script_component.hpp @@ -0,0 +1,9 @@ +#define COMPONENT store +#define COMPONENT_BEAUTIFIED Store +#include "\forge\forge_client\addons\main\script_mod.hpp" + +// #define DEBUG_MODE_FULL +// #define DISABLE_COMPILE_CACHE +// #define ENABLE_PERFORMANCE_COUNTERS + +#include "\forge\forge_client\addons\main\script_macros.hpp" diff --git a/arma/client/addons/store/stringtable.xml b/arma/client/addons/store/stringtable.xml new file mode 100644 index 0000000..f0a0c86 --- /dev/null +++ b/arma/client/addons/store/stringtable.xml @@ -0,0 +1,8 @@ + + + + + Store + + + diff --git a/arma/client/addons/store/ui/RscCommon.hpp b/arma/client/addons/store/ui/RscCommon.hpp new file mode 100644 index 0000000..8b57936 --- /dev/null +++ b/arma/client/addons/store/ui/RscCommon.hpp @@ -0,0 +1,98 @@ +// Control types +#define CT_STATIC 0 +#define CT_BUTTON 1 +#define CT_EDIT 2 +#define CT_SLIDER 3 +#define CT_COMBO 4 +#define CT_LISTBOX 5 +#define CT_TOOLBOX 6 +#define CT_CHECKBOXES 7 +#define CT_PROGRESS 8 +#define CT_HTML 9 +#define CT_STATIC_SKEW 10 +#define CT_ACTIVETEXT 11 +#define CT_TREE 12 +#define CT_STRUCTURED_TEXT 13 +#define CT_CONTEXT_MENU 14 +#define CT_CONTROLS_GROUP 15 +#define CT_SHORTCUTBUTTON 16 +#define CT_HITZONES 17 +#define CT_XKEYDESC 40 +#define CT_XBUTTON 41 +#define CT_XLISTBOX 42 +#define CT_XSLIDER 43 +#define CT_XCOMBO 44 +#define CT_ANIMATED_TEXTURE 45 +#define CT_OBJECT 80 +#define CT_OBJECT_ZOOM 81 +#define CT_OBJECT_CONTAINER 82 +#define CT_OBJECT_CONT_ANIM 83 +#define CT_LINEBREAK 98 +#define CT_USER 99 +#define CT_MAP 100 +#define CT_MAP_MAIN 101 +#define CT_LISTNBOX 102 +#define CT_ITEMSLOT 103 +#define CT_CHECKBOX 77 + +// Static styles +#define ST_POS 0x0F +#define ST_HPOS 0x03 +#define ST_VPOS 0x0C +#define ST_LEFT 0x00 +#define ST_RIGHT 0x01 +#define ST_CENTER 0x02 +#define ST_DOWN 0x04 +#define ST_UP 0x08 +#define ST_VCENTER 0x0C + +#define ST_TYPE 0xF0 +#define ST_SINGLE 0x00 +#define ST_MULTI 0x10 +#define ST_TITLE_BAR 0x20 +#define ST_PICTURE 0x30 +#define ST_FRAME 0x40 +#define ST_BACKGROUND 0x50 +#define ST_GROUP_BOX 0x60 +#define ST_GROUP_BOX2 0x70 +#define ST_HUD_BACKGROUND 0x80 +#define ST_TILE_PICTURE 0x90 +#define ST_WITH_RECT 0xA0 +#define ST_LINE 0xB0 +#define ST_UPPERCASE 0xC0 +#define ST_LOWERCASE 0xD0 + +#define ST_SHADOW 0x100 +#define ST_NO_RECT 0x200 +#define ST_KEEP_ASPECT_RATIO 0x800 + +// Slider styles +#define SL_DIR 0x400 +#define SL_VERT 0 +#define SL_HORZ 0x400 + +#define SL_TEXTURES 0x10 + +// progress bar +#define ST_VERTICAL 0x01 +#define ST_HORIZONTAL 0 + +// Listbox styles +#define LB_TEXTURES 0x10 +#define LB_MULTI 0x20 + +// Tree styles +#define TR_SHOWROOT 1 +#define TR_AUTOCOLLAPSE 2 + +// Default text sizes +#define GUI_TEXT_SIZE_SMALL (GUI_GRID_H * 0.8) +#define GUI_TEXT_SIZE_MEDIUM (GUI_GRID_H * 1) +#define GUI_TEXT_SIZE_LARGE (GUI_GRID_H * 1.2) + +// Pixel grid +#define pixelScale 0.50 +#define GRID_W (pixelW * pixelGrid * pixelScale) +#define GRID_H (pixelH * pixelGrid * pixelScale) + +class RscText; diff --git a/arma/client/addons/store/ui/RscStore.hpp b/arma/client/addons/store/ui/RscStore.hpp new file mode 100644 index 0000000..a68c8d4 --- /dev/null +++ b/arma/client/addons/store/ui/RscStore.hpp @@ -0,0 +1,21 @@ +class RscStore { + idd = 1003; + fadeIn = 0; + fadeOut = 0; + duration = 1e011; + onLoad = "uiNamespace setVariable ['RscStore', _this select 0]"; + onUnLoad = "uinamespace setVariable ['RscStore', nil]"; + + class controlsBackground {}; + class controls { + class IFrame: RscText { + type = 106; + idc = 1004; + x = "safeZoneXAbs"; + y = "safeZoneY"; + w = "safeZoneWAbs"; + h = "safeZoneH"; + colorBackground[] = {0, 0, 0, 0}; + }; + }; +}; diff --git a/arma/client/addons/store/ui/_site/index.html b/arma/client/addons/store/ui/_site/index.html new file mode 100644 index 0000000..87ac510 --- /dev/null +++ b/arma/client/addons/store/ui/_site/index.html @@ -0,0 +1 @@ +FORGE Supply Exchange
\ No newline at end of file diff --git a/arma/client/addons/store/ui/_site/store-ui.css b/arma/client/addons/store/ui/_site/store-ui.css new file mode 100644 index 0000000..92a725c --- /dev/null +++ b/arma/client/addons/store/ui/_site/store-ui.css @@ -0,0 +1 @@ +:root{--store-shell-bg:#e4e3df;--store-surface:#f5f3ef;--store-surface-alt:#ece8e2;--store-surface-strong:#fff;--store-border:#4a5b6e33;--store-border-strong:#142e4f33;--store-text-main:#1f2d3d;--store-text-muted:#6a7787;--store-text-subtle:#8792a0;--store-accent:#12365d;--store-accent-soft:#dbe7f3;--store-accent-line:#12365d1f;--store-success:#2f7d5b;--store-danger:#8a3d3d}*{box-sizing:border-box}html,body{width:100%;height:100%;margin:0;overflow:hidden}body{color:var(--store-text-main);background:var(--store-shell-bg);font-family:Segoe UI,Trebuchet MS,sans-serif}button,input,select{font:inherit}button{cursor:pointer}button:disabled{cursor:not-allowed;opacity:.7}:focus-visible{outline-offset:2px;outline:2px solid #12365d59}#app{width:100%;height:100%}.store-btn{border:1px solid var(--store-border-strong);letter-spacing:.08em;text-transform:uppercase;border-radius:.8rem;min-height:2.75rem;padding:.72rem 1rem;font-size:.82rem;font-weight:700}.store-btn.store-btn-primary{color:var(--store-accent);background:#ffffffad}.store-btn.store-btn-primary:hover{background:#dbe7f3e0}.store-btn.store-btn-secondary{color:var(--store-text-muted);background:#ffffff6b}.store-btn.store-btn-secondary:hover{color:var(--store-text-main);background:#fff9} \ No newline at end of file diff --git a/arma/client/addons/store/ui/_site/store-ui.js b/arma/client/addons/store/ui/_site/store-ui.js new file mode 100644 index 0000000..ffa5a91 --- /dev/null +++ b/arma/client/addons/store/ui/_site/store-ui.js @@ -0,0 +1 @@ +!function(){const e=window.ForgeWebUI;(window.StorefrontApp=window.StorefrontApp||{}).runtime=e,window.AppRuntime=e}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},t=e.runtime,[n,r]=t.createSignal(0),a=Object.create(null),o=Object.create(null),s=[],i=Object.create(null),c=Object.create(null),l=new WeakSet;let d=0,m=null,u=null,g=0;function p(e){let t=String(e||"").trim();if(!t)return"";for(;t.startsWith("\\")||t.startsWith("/");)t=t.slice(1);return/\.[A-Za-z0-9]+$/.test(t)||(t+=".paa"),t}function b(e){const t=String(e||"").trim().toLowerCase();return t.startsWith("data:image/")||t.startsWith("blob:")||t.startsWith("http://")||t.startsWith("https://")}function h(e,t){a[e]=t,function(){if(g)return;g=window.setTimeout(()=>{g=0,r(e=>e+1)},48)}()}function y(){if("undefined"!=typeof A3API&&"function"==typeof A3API.RequestTexture)for(;d<6&&s.length>0;){const e=s.shift();delete i[e],e&&void 0===a[e]&&!o[e]&&(d+=1,o[e]=Promise.resolve(A3API.RequestTexture(e,512)).then(t=>{const n=String(t||"").trim();b(n)?h(e,n):(console.warn("[Store UI] Ignoring unsupported texture response.",e,n),h(e,""))}).catch(t=>{console.warn("[Store UI] Failed to resolve texture.",e,t),h(e,"")}).finally(()=>{d=Math.max(0,d-1),delete o[e],y()}))}}function f(e){!e||i[e]||o[e]||(i[e]=!0,s.push(e),y())}function v(e){const t=p(e);t&&!c[t]&&(c[t]=!0,b(a[t])||o[t]||f(t))}function S(){const e=document.querySelectorAll("[data-store-texture-path]");if(0===e.length)return;const t=function(){const e=document.querySelector(".catalog-grid");return"function"!=typeof IntersectionObserver?null:(m&&u===e||(m&&m.disconnect(),u=e,m=new IntersectionObserver(e=>{e.forEach(e=>{e.isIntersecting&&(v(e.target.getAttribute("data-store-texture-path")),m.unobserve(e.target))})},{root:e,rootMargin:"240px 0px",threshold:.01})),m)}();e.forEach(e=>{if(l.has(e))return;l.add(e);const n=e.getAttribute("data-store-texture-path");t?t.observe(e):v(n)})}e.media={getTextureState:function(e){n();const t=p(e);return{path:t,isVisible:Boolean(t&&c[t]),isLoaded:Boolean(t&&a[t]&&b(a[t]))}},getTextureSource:function(e){n();const t=p(e);return t?b(e)?(a[t]=String(e).trim(),a[t]):void 0!==a[t]?a[t]:c[t]?(f(t),""):"":""},scheduleTextureObservation:function(){window.requestAnimationFrame(()=>{S()})}}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},t={actorName:"",actorUid:"",approval:"Field Access",orgId:"",orgName:"",orgLeader:!1,defaultOrgCeo:!1,canUseOrgFunds:!1},n={budget:5e4,creditLine:0,availability:"In-Stock",moduleState:"Preview",searchTags:["Attachment","Grenade","Medical","Consumable","Static","Scope","Item","Misc"],paymentSources:[{id:"cash",label:"Cash",balance:0,enabled:!1,detail:"Use on-hand cash carried by the player."},{id:"bank",label:"Bank",balance:0,enabled:!1,detail:"Charge the player bank account."},{id:"org_funds",label:"Org Funds",balance:0,enabled:!1,detail:"Only organization leaders or the default-org CEO can use treasury funds."},{id:"credit_line",label:"Credit Line",balance:0,enabled:!1,detail:"No approved credit line is assigned to this member."}],defaultPaymentSource:"cash"};function r(e,t){var n;Object.keys(e).forEach(t=>delete e[t]),Object.assign(e,(n=t,JSON.parse(JSON.stringify(n))))}e.data={catalog:{categoryCards:[{id:"uniforms",label:"Uniforms"},{id:"headgear",label:"Headgear"},{id:"facewear",label:"Facewear"},{id:"vests",label:"Vests"},{id:"backpacks",label:"Backpacks"},{id:"attachments",label:"Attachments"},{id:"weapons",label:"Weapons"},{id:"ammo",label:"Ammo"},{id:"misc",label:"Misc"},{id:"vehicles",label:"Vehicles"}],vehicleCards:[{id:"cars",label:"Cars"},{id:"armor",label:"Armor"},{id:"helis",label:"Helicopters"},{id:"planes",label:"Planes"},{id:"naval",label:"Naval"},{id:"other",label:"Other"}],weaponCards:[{id:"primary",label:"Primary"},{id:"secondary",label:"Secondary"},{id:"handgun",label:"Handgun"}],previewItems:{uniforms:[],headgear:[],facewear:[],vests:[],backpacks:[],attachments:[],ammo:[],misc:[],primary:[],secondary:[],handgun:[],cars:[],armor:[],helis:[],planes:[],naval:[],other:[]}},session:Object.assign({},t),storeConfig:Object.assign({},n),applyHydratePayload(e){r(this.session,Object.assign({},t,e?.session||{})),r(this.storeConfig,Object.assign({},n,e?.storeConfig||{}))}}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{createSignal:t}=e.runtime,n=window.SharedLogic=window.SharedLogic||{};n.createStorefrontStore=function({createSignal:e}){function t(e){return{className:String(e?.className||e?.code||""),code:String(e?.code||e?.className||""),name:String(e?.name||e?.displayName||""),description:String(e?.description||""),price:String(e?.price||""),image:String(e?.image||""),type:String(e?.type||""),category:String(e?.category||""),entryKind:String(e?.entryKind||"item"),quantity:Math.max(0,Number(e?.quantity||0))}}function n(e){return{code:String(e?.code||""),name:String(e?.name||""),price:String(e?.price||"$0"),category:String(e?.category||""),entryKind:String(e?.entryKind||"item"),quantity:Math.max(1,Number(e?.quantity||1))}}return new class{constructor(){[this.getView,this.setView]=e("categories"),[this.getSelectedCategory,this.setSelectedCategory]=e(""),[this.getSelectedWeaponSlot,this.setSelectedWeaponSlot]=e(""),[this.getSelectedVehicleSlot,this.setSelectedVehicleSlot]=e(""),[this.getCartOpen,this.setCartOpen]=e(!1),[this.getSearchQuery,this.setSearchQuery]=e(""),[this.getCartItems,this.setCartItems]=e([]),[this.getCatalogItemsByKey,this.setCatalogItemsByKey]=e({}),[this.getIsCatalogLoading,this.setIsCatalogLoading]=e(!1),[this.getCatalogRequestKey,this.setCatalogRequestKey]=e(""),[this.getCatalogPage,this.setCatalogPage]=e(1),[this.getNotice,this.setNotice]=e({type:"",text:""}),[this.getIsCheckingOut,this.setIsCheckingOut]=e(!1),[this.getSelectedPaymentSource,this.setSelectedPaymentSource]=e("cash")}resetToCategories(){this.setView("categories"),this.setSelectedCategory(""),this.setSelectedWeaponSlot(""),this.setSelectedVehicleSlot(""),this.setIsCatalogLoading(!1),this.setCatalogRequestKey(""),this.setCatalogPage(1)}openWeaponsRoot(){this.setView("weapons"),this.setSelectedCategory("weapons"),this.setSelectedWeaponSlot(""),this.setSelectedVehicleSlot(""),this.setIsCatalogLoading(!1),this.setCatalogRequestKey(""),this.setCatalogPage(1)}openVehiclesRoot(){this.setView("vehicles"),this.setSelectedCategory("vehicles"),this.setSelectedVehicleSlot(""),this.setSelectedWeaponSlot(""),this.setIsCatalogLoading(!1),this.setCatalogRequestKey(""),this.setCatalogPage(1)}resetCatalogPage(){this.setCatalogPage(1)}setCatalogPageNumber(e){const t=Math.max(1,Number(e||1));this.setCatalogPage(t)}selectCategory(e){this.setSelectedCategory(e),this.setSelectedWeaponSlot(""),this.setSelectedVehicleSlot(""),this.setCatalogPage(1),"weapons"!==e?"vehicles"!==e?this.setView("items"):this.openVehiclesRoot():this.openWeaponsRoot()}selectSubcategory(e,t){"vehicle"===t?(this.setSelectedVehicleSlot(e),this.setSelectedWeaponSlot("")):(this.setSelectedWeaponSlot(e),this.setSelectedVehicleSlot("")),this.setCatalogPage(1),this.setView("items")}startCategoryRequest(e){const t=String(e||"").trim().toLowerCase();return!!t&&(this.setCatalogRequestKey(t),this.setIsCatalogLoading(!0),!0)}finishCategoryRequest(e){const t=String(e||"").trim().toLowerCase(),n=String(this.getCatalogRequestKey()||"").trim().toLowerCase();t&&n&&n!==t||(this.setCatalogRequestKey(""),this.setIsCatalogLoading(!1))}hydrateCategoryItems(e){const n=String(e?.category||"").trim().toLowerCase(),r=Array.isArray(e?.items)?e.items:[];if(!n)return this.setCatalogRequestKey(""),void this.setIsCatalogLoading(!1);this.setCatalogItemsByKey(e=>Object.assign({},e,{[n]:r.map(t)})),this.finishCategoryRequest(n)}ensureSelectedPaymentSource(e){const t=Array.isArray(e?.paymentSources)?e.paymentSources:[],n=String(this.getSelectedPaymentSource()||"").trim(),r=String(e?.defaultPaymentSource||"").trim(),a=t.map(e=>String(e?.id||"").trim()),o=t.find(e=>e&&!1!==e.enabled),s=r&&a.includes(r)?t.find(e=>String(e?.id||"").trim()===r):null;n&&a.includes(n)&&t.some(e=>String(e?.id||"").trim()===n&&!1!==e?.enabled)||(s&&!1!==s.enabled?this.setSelectedPaymentSource(r):o?this.setSelectedPaymentSource(String(o.id||"cash")):this.setSelectedPaymentSource(r||"cash"))}navigateToBreadcrumb(e){switch(e){case"categories":return this.resetToCategories(),!0;case"weapons":return this.openWeaponsRoot(),!0;case"vehicles":return this.openVehiclesRoot(),!0;default:return!1}}hydrateFromPayload(e){const t=Array.isArray(e?.cartItems)?e.cartItems:[];this.setCartItems(t.map(n)),this.setCartOpen(!1),this.setIsCheckingOut(!1),this.setCatalogItemsByKey({}),this.setCatalogRequestKey(""),this.setIsCatalogLoading(!1),this.setCatalogPage(1),this.ensureSelectedPaymentSource(e?.storeConfig||{})}hydrateStoreConfig(e){const t=Array.isArray(e?.cartItems)?e.cartItems:[];this.setCartItems(t.map(n)),this.setCartOpen(!1),this.setIsCheckingOut(!1),this.ensureSelectedPaymentSource(e?.storeConfig||{})}}},e.store=n.createStorefrontStore({createSignal:t})}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{};function t(e){return e.selectedWeaponSlot||e.selectedVehicleSlot||e.selectedCategory}function n(e,t){if(!e)return!0;const n=String(e).trim().toLowerCase();return!n||t.some(e=>String(e||"").toLowerCase().includes(n))}function r(e){const t=Number(String(e||"0").replace(/[^0-9.-]+/g,""));return Number.isFinite(t)?t:0}function a(e){const t=String(e||"").trim().toLowerCase();return["items","misc"].includes(t)?"Misc":String(e||"").replace(/[-_]+/g," ").split(/\s+/).filter(Boolean).map(e=>e.charAt(0).toUpperCase()+e.slice(1).toLowerCase()).join(" ")}function o(e,r){const a=t(e),o=String(a||"").trim().toLowerCase(),s=e.catalogItemsByKey||{};return(Array.isArray(s[o])?s[o]:[]).filter(t=>n(e.searchQuery,[t.className,t.code,t.name,t.description,t.price,t.type]))}function s(e,t){const n=o(e).length,r=Math.max(1,Math.ceil(n/6)),a=Math.min(r,Math.max(1,Number(e.catalogPage||1)));return{pageSize:6,totalItems:n,totalPages:r,currentPage:a,startIndex:0===n?0:6*(a-1)+1,endIndex:Math.min(6*a,n)}}function i(e){return(Array.isArray(e?.paymentSources)?e.paymentSources:[]).map(e=>({id:String(e?.id||"").trim(),label:String(e?.label||e?.id||"").trim(),balance:Number(e?.balance||0),enabled:!1!==e?.enabled,detail:String(e?.detail||"").trim()}))}e.getters={formatTitle:a,formatCurrency:function(e){return`$${Number(e||0).toLocaleString()}`},parsePrice:r,getSelectionKey:t,getStoreState:function(e){return{view:e.getView(),selectedCategory:e.getSelectedCategory(),selectedWeaponSlot:e.getSelectedWeaponSlot(),selectedVehicleSlot:e.getSelectedVehicleSlot(),selectedPaymentSource:e.getSelectedPaymentSource(),cartOpen:e.getCartOpen(),searchQuery:e.getSearchQuery(),cartItems:e.getCartItems(),catalogItemsByKey:e.getCatalogItemsByKey(),isCatalogLoading:e.getIsCatalogLoading(),catalogRequestKey:e.getCatalogRequestKey(),catalogPage:e.getCatalogPage(),isCheckingOut:e.getIsCheckingOut()}},getStoreHeader:function(e){if("weapons"===e.view)return{eyebrow:"Weapons Division",title:"Weapon Categories",copy:"Select a weapon slot to open the next supply tier. Primary, secondary, and handgun are staged with the same state and bridge flow as the org portal.",badge:"3 Slots"};if("vehicles"===e.view)return{eyebrow:"Vehicle Motorpool",title:"Vehicle Categories",copy:"Select a vehicle class to open the next supply tier. Cars, armor, airframes, and naval options stay inside the same local store and bridge lifecycle.",badge:"6 Classes"};if("items"===e.view){const n=t(e)||"catalog",r=e.searchQuery?` Filtered by "${e.searchQuery}".`:"",o=e.isCatalogLoading?" Pulling live inventory from the game engine.":"";return{eyebrow:"Catalog Preview",title:a(n),copy:`Live category inventory generated from the game engine for the selected department.${r}${o}`,badge:"Preview Items"}}return{eyebrow:"Supply Categories",title:"Procurement Dashboard",copy:"Choose a category to enter the exchange. Weapons and vehicles open a second tier, while the other departments display placeholder product inventory inside the new runtime/store architecture.",badge:"8 Categories"}},getStoreBreadcrumbs:function(e){const t=[{id:"categories",label:"Supply Exchange"}];if("weapons"===e.view)return t.push({id:"weapons",label:"Weapons"}),t;if("vehicles"===e.view)return t.push({id:"vehicles",label:"Vehicles"}),t;if("items"===e.view){if(e.selectedWeaponSlot)return t.push({id:"weapons",label:"Weapons"}),t.push({id:"weapon-slot",label:a(e.selectedWeaponSlot)}),t;if(e.selectedVehicleSlot)return t.push({id:"vehicles",label:"Vehicles"}),t.push({id:"vehicle-slot",label:a(e.selectedVehicleSlot)}),t;e.selectedCategory&&t.push({id:"category",label:a(e.selectedCategory)})}return t},getVisibleCategoryCards:function(e,t){return t.categoryCards.filter(t=>n(e.searchQuery,[t.id,t.label]))},getVisibleSubcategoryCards:function(e,t){return("vehicles"===e.view?t.vehicleCards:t.weaponCards).filter(t=>n(e.searchQuery,[t.id,t.label]))},getVisibleItems:o,getVisibleItemsPage:function(e,t){const n=o(e),r=s(e),a=(r.currentPage-1)*r.pageSize;return n.slice(a,a+r.pageSize)},getCatalogPagination:s,summarizeCart:function(e){const t=e.reduce((e,t)=>e+Number(t.quantity||0),0),n=e.reduce((e,t)=>e+r(t.price)*Number(t.quantity||0),0);return{lineCount:e.length,itemCount:t,subtotal:n,total:n}},getPaymentSources:i,getPaymentSourceById:function(e,t){const n=String(t||"").trim();return i(e).find(e=>e.id===n)}}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},t=e.store,n=window.ForgeWebUI.createBridge({closeEvent:"store::close",globalName:"StoreUIBridge",readyEvent:"store::ready"});n.on("store::hydrate",n=>{e.data.applyHydratePayload(n),t.hydrateFromPayload(n)}),n.on("store::config::hydrate",n=>{e.data.applyHydratePayload(n),t.hydrateStoreConfig(n)}),n.on("store::checkout::success",n=>{t.setIsCheckingOut(!1),t.setCartItems([]),t.setCartOpen(!1),e.actions&&e.actions.showNotice("success",n.message||"Checkout completed.")}),n.on("store::category::hydrate",e=>{t.hydrateCategoryItems(e)}),n.on("store::category::failure",n=>{t.finishCategoryRequest(n.category||""),e.actions&&e.actions.showNotice("error",n.message||"Category request failed.")}),n.on("store::checkout::failure",n=>{t.setIsCheckingOut(!1),e.actions&&e.actions.showNotice("error",n.message||"Checkout failed.")}),e.bridge={close:n.close,requestClose:function(){return n.close({})},requestCheckout:function(e){return n.send("store::checkout::request",e)},requestCategory:function(e){return n.send("store::category::request",e)},notifyReady:function(){return n.ready({loaded:!0})},receive:n.receive,sendEvent:n.send}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},t=e.store,n=e.getters,{storeConfig:r,session:a}=e.data;let o=null;function s(e,n){t.setNotice({type:e,text:n}),o&&clearTimeout(o),o=setTimeout(()=>{t.setNotice({type:"",text:""}),o=null},3200)}function i(e,t,n){const r={items:[],vehicles:[],totalPrice:n,paymentMethod:t};return e.forEach(e=>{const t=function(e){return{classname:String(e?.code||"").trim(),category:String(e?.category||"").trim().toLowerCase(),entryKind:String(e?.entryKind||"item").trim().toLowerCase(),quantity:Math.max(1,Number(e?.quantity||1))}}(e);if("vehicle"!==t.entryKind)r.items.push({classname:t.classname,category:t.category,quantity:t.quantity});else for(let e=0;e!e)},closeCart:function(){t.setCartOpen(!1)},closeStore:function(){const t=e.bridge;if(t&&"function"==typeof t.requestClose){if(t.requestClose())return!0}return s("error","Store bridge is unavailable."),!1},navigateToBreadcrumb:function(e){return t.navigateToBreadcrumb(e)},selectCategory:function(e){t.selectCategory(e),c(),["weapons","vehicles"].includes(String(e||""))||d(e)},selectSubcategory:function(e,n){t.selectSubcategory(e,n),c(),d(e)},goToCatalogPage:l,goToNextCatalogPage:function(e){const n=Number(t.getCatalogPage()||1);return!(n>=Math.max(1,Number(e||1)))&&(l(n+1),!0)},goToPreviousCatalogPage:function(){const e=Number(t.getCatalogPage()||1);return!(e<=1)&&(l(e-1),!0)},addToCart:function(e){t.setCartItems(t=>{const n=t.findIndex(t=>t.code===e.code);if(-1===n)return[...t,{code:e.code,name:e.name,price:e.price,category:e.category,entryKind:e.entryKind,quantity:1}];const r=[...t];return r[n]=Object.assign({},r[n],{category:e.category,entryKind:e.entryKind,quantity:r[n].quantity+1}),r}),s("success",`${e.name} added to the acquisition queue.`)},incrementCartItem:function(e){t.setCartItems(t=>t.map(t=>t.code===e?Object.assign({},t,{quantity:t.quantity+1}):t))},decrementCartItem:function(e){t.setCartItems(t=>t.map(t=>t.code===e?Object.assign({},t,{quantity:Math.max(0,t.quantity-1)}):t).filter(e=>e.quantity>0))},removeCartItem:function(e){t.setCartItems(t=>t.filter(t=>t.code!==e))},selectPaymentSource:function(e){const a=String(e||"").trim(),o=n.getPaymentSources(r).find(e=>e.id===a);return o?!1===o.enabled?(s("error",o.detail||"Selected payment source is not available."),!1):(t.setSelectedPaymentSource(a),!0):(s("error","Selected payment source is unavailable."),!1)},requestCheckout:function(){const a=t.getCartItems();if(0===a.length)return s("error","Add at least one item before checkout."),!1;const o=n.summarizeCart(a),c=n.getPaymentSourceById(r,t.getSelectedPaymentSource());if(!c)return s("error","Select a payment source before checkout."),!1;if(!1===c.enabled)return s("error",c.detail||"Selected payment source is unavailable."),!1;if(o.total>Number(c.balance||0))return s("error",`${c.label} cannot cover this checkout total.`),!1;const l=e.bridge;if(!l||"function"!=typeof l.requestCheckout)return s("error","Checkout bridge is unavailable."),!1;t.setIsCheckingOut(!0);const d=i(a,c.id,o.total);return!!l.requestCheckout({checkoutJson:JSON.stringify(d)})||(t.setIsCheckingOut(!1),s("error","Checkout bridge is unavailable."),!1)},formatTitle:n.formatTitle,formatCurrency:n.formatCurrency}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{h:t,ensureScopedStyle:n}=e.runtime,r=window.SharedUI.componentFns.WindowTitleBar,a=e.store,o=e.getters,s=e.actions,{catalog:i,session:c,storeConfig:l}=e.data,d="data-ui-store-app-shell",m=`[${d}]`,u=`\n${m} {\n display: flex;\n flex-direction: column;\n width: 100%;\n height: 100%;\n overflow: hidden;\n background: var(--store-shell-bg);\n}\n\n${m} .footer-title,\n${m} .eyebrow {\n font-size: 0.68rem;\n letter-spacing: 0.18em;\n text-transform: uppercase;\n color: var(--store-text-subtle);\n font-weight: 700;\n}\n\n${m} .module-header,\n${m} .store-panel-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n}\n\n${m} .store-app {\n flex: 1;\n min-height: 0;\n width: min(100%, 1613px);\n margin: 0 auto;\n display: grid;\n grid-template-columns: 308px minmax(0, 1fr);\n gap: 1.25rem;\n padding: 1.25rem;\n}\n\n${m} .store-sidebar,\n${m} .store-main {\n min-height: 0;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n}\n\n${m} .store-main {\n position: relative;\n overflow: hidden;\n}\n\n${m} .module-card,\n${m} .store-panel {\n background: linear-gradient(180deg, var(--store-surface) 0%, var(--store-surface-alt) 100%);\n border: 1px solid var(--store-border);\n border-radius: 1.35rem;\n}\n\n${m} .module-card {\n padding: 1rem;\n}\n\n${m} .store-panel {\n min-height: 0;\n flex: 1 1 auto;\n display: flex;\n flex-direction: column;\n width: min(100%, 1280px);\n overflow: hidden;\n}\n\n${m} .module-header {\n margin-bottom: 0.85rem;\n}\n\n${m} .store-panel-header {\n padding: 1rem 1rem 0;\n}\n\n${m} .section-title {\n margin: 0;\n font-size: 1.1rem;\n font-weight: 700;\n letter-spacing: -0.02em;\n color: var(--store-text-main);\n}\n\n${m} .section-copy,\n${m} .footer-copy {\n margin: 0.2rem 0 0;\n font-size: 0.9rem;\n line-height: 1.45;\n color: var(--store-text-muted);\n}\n\n${m} .pill {\n padding: 0.48rem 0.8rem;\n border-radius: 999px;\n background: var(--store-accent-soft);\n color: var(--store-accent);\n font-size: 0.74rem;\n font-weight: 700;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n}\n\n${m} .search-module {\n display: flex;\n flex-direction: column;\n gap: 0.8rem;\n}\n\n${m} .search-form {\n display: grid;\n gap: 0.7rem;\n}\n\n${m} .search-input {\n width: 100%;\n height: 2.9rem;\n padding: 0 0.95rem;\n border-radius: 0.8rem;\n border: 1px solid var(--store-border);\n background: rgb(255 255 255 / 0.75);\n color: var(--store-text-main);\n}\n\n${m} .quick-tags {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n}\n\n${m} .quick-tag {\n padding: 0.55rem 0.72rem;\n border-radius: 999px;\n border: 1px solid var(--store-border);\n background: rgb(255 255 255 / 0.52);\n color: var(--store-text-muted);\n font-size: 0.75rem;\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n${m} .filter-stack {\n display: grid;\n gap: 0.85rem;\n}\n\n${m} .filter-group {\n padding: 0.95rem;\n border-radius: 0.8rem;\n background: rgb(255 255 255 / 0.48);\n border: 1px solid var(--store-border);\n}\n\n${m} .filter-label {\n display: block;\n margin-bottom: 0.55rem;\n font-size: 0.72rem;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--store-text-subtle);\n font-weight: 700;\n}\n\n${m} .filter-value {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n color: var(--store-text-main);\n font-size: 0.92rem;\n font-weight: 600;\n}\n\n${m} .filter-placeholder {\n color: var(--store-text-muted);\n font-weight: 500;\n}\n\n${m} .store-panel-intro {\n padding: 0 1rem 1rem;\n border-bottom: 1px solid var(--store-accent-line);\n}\n\n${m} .store-footer-bar {\n width: 100%;\n border-top: 1px solid rgb(18 54 93 / 0.1);\n background: transparent;\n}\n\n${m} .store-footer {\n width: min(100%, 1613px);\n margin: 0 auto;\n display: grid;\n grid-template-columns: repeat(3, minmax(0, 1fr));\n gap: 1rem;\n padding: 0.95rem 1.25rem 1.15rem;\n}\n\n${m} .footer-block {\n display: flex;\n flex-direction: column;\n gap: 0.25rem;\n}\n\n${m} .store-toast-stack {\n position: fixed;\n top: 1.2rem;\n right: 1.5rem;\n z-index: 10;\n display: flex;\n flex-direction: column;\n gap: 0.65rem;\n}\n\n${m} .store-toast {\n max-width: 24rem;\n padding: 0.85rem 1rem;\n border-radius: 0.9rem;\n border: 1px solid var(--store-border);\n background: #fff;\n box-shadow: 0 14px 28px rgb(16 34 56 / 0.14);\n font-size: 0.92rem;\n}\n\n${m} .store-toast.is-success {\n background: #ecfdf5;\n border-color: #bbf7d0;\n color: #166534;\n}\n\n${m} .store-toast.is-error {\n background: #fef2f2;\n border-color: #fecaca;\n color: #991b1b;\n}\n\n@media (max-width: 1440px) {\n ${m} .store-app {\n grid-template-columns: 284px minmax(0, 1fr);\n }\n}\n\n@media (max-width: 1120px) {\n ${m} .store-app {\n grid-template-columns: 1fr;\n overflow: auto;\n }\n\n ${m} .store-sidebar,\n ${m} .store-main {\n min-height: auto;\n }\n\n ${m} .store-main {\n overflow: visible;\n }\n\n ${m} .store-footer {\n grid-template-columns: 1fr;\n }\n\n ${m} .store-toast-stack {\n right: 1rem;\n left: 1rem;\n }\n}\n`;e.components=e.components||{},e.componentFns=e.componentFns||{},e.components.App=function(){const m=e.componentFns.Navbar,g=e.componentFns.Cart,p=o.getStoreState(a),b=o.getStoreHeader(p),h=a.getNotice(),y=p.searchQuery,f=o.getPaymentSources(l).filter(e=>!1!==e.enabled).length,v="items"===p.view?s.formatTitle(o.getSelectionKey(p)||"Catalog"):s.formatTitle(p.view),S=o.getPaymentSourceById(l,p.selectedPaymentSource)||null;return n("storefront-app-shell",u),t("div",{[d]:""},r({kicker:"FORGE Logistics",title:"Supply Exchange",onClose:()=>s.closeStore(),closeLabel:"Close store interface"}),h.text?t("div",{className:"store-toast-stack"},t("div",{className:"error"===h.type?"store-toast is-error":"store-toast is-success"},h.text)):null,t("div",{className:"store-app"},t("aside",{className:"store-sidebar"},t("section",{className:"module-card search-module"},t("div",{className:"module-header"},t("div",null,t("span",{className:"eyebrow"},"Search"),t("h2",{className:"section-title"},"Inventory Search")),t("span",{className:"pill"},"Live")),t("div",{className:"search-form"},t("input",{id:"store-search-input",type:"text",className:"search-input",placeholder:"Search inventory, classes, or suppliers",value:y}),t("div",{style:{display:"flex",gap:"0.65rem"}},t("button",{type:"button",className:"store-btn store-btn-primary",onClick:()=>s.applySearchQuery(document.getElementById("store-search-input")?.value||"")},"Apply Search"),t("button",{type:"button",className:"store-btn store-btn-secondary",onClick:()=>s.clearSearch()},"Clear"))),t("div",{className:"quick-tags"},(l.searchTags||[]).map(e=>t("span",{className:"quick-tag"},e)))),t("section",{className:"module-card"},t("div",{className:"module-header"},t("div",null,t("span",{className:"eyebrow"},"Filter"),t("h2",{className:"section-title"},"Procurement Filters")),t("span",{className:"pill"},l.moduleState)),t("div",{className:"filter-stack"},t("div",{className:"filter-group"},t("span",{className:"filter-label"},"Department"),t("div",{className:"filter-value"},t("span",{className:"filter-placeholder"},v))),t("div",{className:"filter-group"},t("span",{className:"filter-label"},"Availability"),t("div",{className:"filter-value"},t("span",{className:"filter-placeholder"},l.availability))),t("div",{className:"filter-group"},t("span",{className:"filter-label"},"Payment"),t("div",{className:"filter-value"},t("span",{className:"filter-placeholder"},S?S.label:"Cash")))))),t("main",{className:"store-main"},t("section",{className:"store-panel"},m(),t("div",{className:"store-panel-header"},t("div",null,t("span",{className:"eyebrow"},b.eyebrow),t("h1",{className:"section-title"},b.title)),t("span",{className:"pill"},b.badge)),t("div",{className:"store-panel-intro"},t("p",{className:"section-copy"},b.copy)),function(t){const{CategoryCard:n,SubcategoryCard:r,ProductCard:a,EmptyStateCard:c,CategoryGrid:l,SubcategoryGrid:d,ProductGrid:m,CatalogPager:u}=e.componentFns;if("weapons"===t.view||"vehicles"===t.view){const e="vehicles"===t.view?"vehicle":"weapon",n=o.getVisibleSubcategoryCards(t,i);return d(n.length>0?n.map(t=>r(t,e)):c({title:"No matching slots",copy:"Try a different search query or clear the current filter.",actionLabel:"Clear Search",onAction:()=>s.clearSearch()}))}if("items"===t.view){const e=o.getVisibleItems(t,i),n=o.getVisibleItemsPage(t,i),r=o.getCatalogPagination(t,i),l=t.cartItems.reduce((e,t)=>(e[t.code]=t.quantity,e),{}),d=String(o.getSelectionKey(t)||"").toLowerCase();return[m(t.isCatalogLoading&&t.catalogRequestKey===d&&0===e.length?c({title:"Loading inventory",copy:"Pulling live category items from the game engine."}):e.length>0?n.map(e=>a(e,l[e.code]||0)):c({title:"No category items",copy:t.searchQuery?"Your search filter excluded the live inventory returned for this category.":"The game engine did not return any items for this category yet.",actionLabel:"Clear Search",onAction:()=>s.clearSearch()})),e.length>0?u(r):null]}const g=o.getVisibleCategoryCards(t,i);return l(g.length>0?g.map(e=>n(e)):c({title:"No matching departments",copy:"Your search filter excluded every top-level department.",actionLabel:"Clear Search",onAction:()=>s.clearSearch()}))}(p)),g())),t("footer",{className:"store-footer-bar"},t("div",{className:"store-footer"},t("div",{className:"footer-block"},t("span",{className:"footer-title"},"Procurement Desk"),t("span",{className:"footer-copy"},"Authorized supply browsing for personnel loadout preparation and mission staging.")),t("div",{className:"footer-block"},t("span",{className:"footer-title"},"Catalog Scope"),t("span",{className:"footer-copy"},"Uniforms, protective gear, weapon slots, vehicles, ammunition groups, and general support inventory.")),t("div",{className:"footer-block"},t("span",{className:"footer-title"},"Purchase Access"),t("span",{className:"footer-copy"},`${c.approval} approval. ${f} payment source(s) currently available${c.orgName?` for ${c.orgName}.`:"."}`)))))}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{h:t,ensureScopedStyle:n}=e.runtime,r=e.actions,a=e.media,o="data-ui-store-cards",s=`[${o}]`,i=`\n${s}.catalog-grid-shell {\n flex: 1;\n min-height: 0;\n display: flex;\n}\n\n${s}.catalog-pager-shell {\n display: block;\n}\n\n${s} .catalog-grid {\n flex: 1;\n min-height: 0;\n width: 100%;\n padding: 1rem;\n display: grid;\n gap: 1rem;\n align-content: start;\n overflow-y: auto;\n overflow-x: hidden;\n scrollbar-gutter: stable;\n scrollbar-width: auto;\n scrollbar-color: rgb(120 136 155 / 0.9) rgb(255 255 255 / 0.45);\n}\n\n${s} .catalog-grid::-webkit-scrollbar {\n width: 12px;\n}\n\n${s} .catalog-grid::-webkit-scrollbar-track {\n background: rgb(255 255 255 / 0.45);\n border-radius: 999px;\n}\n\n${s} .catalog-grid::-webkit-scrollbar-thumb {\n background: rgb(120 136 155 / 0.9);\n border-radius: 999px;\n border: 2px solid rgb(255 255 255 / 0.45);\n}\n\n${s} .catalog-grid.is-categories,\n${s} .catalog-grid.is-products {\n grid-template-columns: repeat(3, minmax(0, 1fr));\n}\n\n${s} .catalog-grid.is-subcategories {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n}\n\n${s} .card-button,\n${s} .product-card,\n${s} .empty-state {\n border: 1px solid var(--store-border);\n border-radius: 1.15rem;\n background:\n linear-gradient(180deg, rgb(255 255 255 / 0.72) 0%, rgb(226 233 239 / 0.9) 100%),\n var(--store-surface-strong);\n color: var(--store-accent);\n box-shadow:\n inset 0 1px 0 rgb(255 255 255 / 0.8),\n 0 10px 24px rgb(16 34 56 / 0.06);\n}\n\n${s} .card-button {\n min-height: 12.5rem;\n display: flex;\n flex-direction: column;\n justify-content: center;\n gap: 0.75rem;\n padding: 1.35rem;\n text-align: left;\n transition:\n transform 120ms ease,\n box-shadow 120ms ease,\n border-color 120ms ease;\n}\n\n${s} .card-button:hover,\n${s} .product-card:hover {\n transform: translateY(-2px);\n border-color: rgb(18 54 93 / 0.32);\n box-shadow:\n 0 16px 28px rgb(16 34 56 / 0.11),\n inset 0 1px 0 rgb(255 255 255 / 0.88);\n}\n\n${s} .card-kicker,\n${s} .product-code,\n${s} .empty-state-kicker {\n font-size: 0.72rem;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n font-weight: 700;\n color: var(--store-text-subtle);\n}\n\n${s} .card-label {\n font-size: 1.08rem;\n font-weight: 700;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n}\n\n${s} .card-copy,\n${s} .product-copy,\n${s} .empty-state-copy {\n margin: 0;\n color: var(--store-text-muted);\n line-height: 1.45;\n}\n\n${s} .product-copy {\n white-space: pre-line;\n}\n\n${s} .product-card {\n min-height: 15.5rem;\n padding: 0.8rem;\n display: flex;\n flex-direction: column;\n gap: 0.65rem;\n}\n\n${s} .product-image {\n height: 5.9rem;\n border-radius: 0.95rem;\n border: 1px dashed rgb(18 54 93 / 0.24);\n background: linear-gradient(135deg, rgb(235 240 245) 0%, rgb(221 228 235) 100%);\n display: flex;\n align-items: center;\n justify-content: center;\n color: var(--store-text-subtle);\n font-size: 0.78rem;\n letter-spacing: 0.16em;\n text-transform: uppercase;\n overflow: hidden;\n}\n\n${s} .product-image-asset {\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n\n${s} .product-meta {\n display: flex;\n flex-direction: column;\n gap: 0.2rem;\n}\n\n${s} .product-name {\n font-size: 0.96rem;\n font-weight: 700;\n color: var(--store-text-main);\n line-height: 1.3;\n}\n\n${s} .product-footer {\n margin-top: auto;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.75rem;\n}\n\n${s} .product-price {\n font-size: 0.96rem;\n font-weight: 700;\n color: var(--store-success);\n}\n\n${s} .product-qty {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 1.85rem;\n height: 1.85rem;\n border-radius: 999px;\n background: var(--store-accent-soft);\n color: var(--store-accent);\n font-size: 0.76rem;\n font-weight: 700;\n}\n\n${s} .empty-state {\n padding: 1.35rem;\n display: flex;\n flex-direction: column;\n gap: 0.65rem;\n}\n\n${s} .catalog-pager {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.9rem;\n padding: 0.55rem 0.9rem 0.75rem;\n border-top: 1px solid var(--store-accent-line);\n}\n\n${s} .catalog-pager-meta {\n display: flex;\n flex-direction: column;\n gap: 0.15rem;\n}\n\n${s} .catalog-pager-summary {\n font-size: 0.86rem;\n color: var(--store-text-muted);\n}\n\n${s} .catalog-pager-actions {\n display: inline-flex;\n align-items: center;\n gap: 0.6rem;\n}\n\n${s} .catalog-pager-page {\n min-width: 5.75rem;\n text-align: center;\n font-size: 0.82rem;\n font-weight: 700;\n color: var(--store-accent);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n${s} .product-copy {\n display: -webkit-box;\n overflow: hidden;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n}\n\n@media (max-width: 1440px) {\n ${s} .catalog-grid.is-categories,\n ${s} .catalog-grid.is-products {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n}\n\n@media (max-width: 1120px) {\n ${s} .catalog-grid.is-categories,\n ${s} .catalog-grid.is-subcategories,\n ${s} .catalog-grid.is-products {\n grid-template-columns: 1fr;\n }\n}\n`;function c(e,r){return n("storefront-cards",i),"is-products"===e&&a&&"function"==typeof a.scheduleTextureObservation&&a.scheduleTextureObservation(),t("div",{[o]:"",className:"catalog-grid-shell"},t("div",{className:`catalog-grid ${e}`,"data-preserve-scroll-id":"catalog-grid"},r))}e.componentFns=e.componentFns||{},e.componentFns.CategoryCard=function(e){return t("button",{type:"button",className:"card-button",onClick:()=>r.selectCategory(e.id)},t("span",{className:"card-kicker"},"Department"),t("strong",{className:"card-label"},e.label),t("p",{className:"card-copy"},"Open this department and move into staged inventory browsing."))},e.componentFns.SubcategoryCard=function(e,n){return t("button",{type:"button",className:"card-button",onClick:()=>r.selectSubcategory(e.id,n)},t("span",{className:"card-kicker"},"vehicle"===n?"Vehicle Class":"Weapon Slot"),t("strong",{className:"card-label"},e.label),t("p",{className:"card-copy"},"Open the next tier and review product previews for this selection."))},e.componentFns.ProductCard=function(e,n){const o=a&&"function"==typeof a.getTextureState?a.getTextureState(e.image):{isVisible:!0},s=a&&"function"==typeof a.getTextureSource?a.getTextureSource(e.image):"",i=function(e,t){const n=String(e||"").trim();if(!n)return t;const r=n.replace(/<\s*br\s*\/?\s*>/gi,"\n").replace(/<\/\s*p\s*>/gi,"\n").replace(/<\s*li\s*>/gi,"- ").replace(/<\/\s*li\s*>/gi,"\n"),a=document.createElement("div");return a.innerHTML=r,String(a.textContent||a.innerText||"").replace(/\u00a0/g," ").replace(/[ \t]+\n/g,"\n").replace(/\n{3,}/g,"\n\n").trim()||t}(e.description,e.className||e.code);return t("article",{className:"product-card"},t("div",{className:"product-image","data-store-texture-path":e.image||""},s?t("img",{className:"product-image-asset",src:s,alt:e.name,loading:"lazy"}):o.isVisible?"Loading Image":"Image Placeholder"),t("div",{className:"product-meta"},t("span",{className:"product-code"},e.type||e.code||e.className),t("strong",{className:"product-name"},e.name)),t("p",{className:"product-copy"},i),t("div",{className:"product-footer"},t("span",{className:"product-price"},e.price||"Pending"),t("div",{style:{display:"flex",alignItems:"center",gap:"0.55rem"}},n>0?t("span",{className:"product-qty"},n):null,t("button",{type:"button",className:"store-btn store-btn-primary",onClick:()=>r.addToCart(e)},"Add to Cart"))))},e.componentFns.EmptyStateCard=function({title:e,copy:n,actionLabel:r,onAction:a}){return t("article",{className:"empty-state"},t("span",{className:"empty-state-kicker"},"No Results"),t("strong",{className:"card-label"},e),t("p",{className:"empty-state-copy"},n),r&&"function"==typeof a?t("button",{type:"button",className:"store-btn store-btn-secondary",onClick:a},r):null)},e.componentFns.CategoryGrid=function(e){return c("is-categories",e)},e.componentFns.SubcategoryGrid=function(e){return c("is-subcategories",e)},e.componentFns.ProductGrid=function(e){return c("is-products",e)},e.componentFns.CatalogPager=function({currentPage:e,totalPages:a,startIndex:s,endIndex:c,totalItems:l}){return n("storefront-cards",i),t("div",{[o]:"",className:"catalog-pager-shell"},t("div",{className:"catalog-pager"},t("div",{className:"catalog-pager-meta"},t("span",{className:"card-kicker"},"Catalog Page"),t("span",{className:"catalog-pager-summary"},l>0?`Showing ${s}-${c} of ${l} items`:"No items available")),t("div",{className:"catalog-pager-actions"},t("button",{type:"button",className:"store-btn store-btn-secondary",disabled:e<=1,onClick:()=>r.goToPreviousCatalogPage()},"Previous"),t("span",{className:"catalog-pager-page"},`Page ${e} / ${a}`),t("button",{type:"button",className:"store-btn store-btn-secondary",disabled:e>=a,onClick:()=>r.goToNextCatalogPage(a)},"Next"))))}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{h:t,ensureScopedStyle:n}=e.runtime,r=e.store,a=e.getters,o=e.actions,{storeConfig:s}=e.data,i="data-ui-store-cart",c=`[${i}]`,l=`\n${c} {\n position: absolute;\n inset: 0;\n z-index: 4;\n pointer-events: none;\n}\n\n${c}.is-open {\n pointer-events: auto;\n}\n\n${c} .store-cart {\n position: absolute;\n top: 0.5rem;\n right: 0.5rem;\n bottom: 0.5rem;\n width: min(24rem, calc(100% - 1rem));\n transform: translateX(calc(100% + 1rem));\n transition: transform 180ms ease;\n}\n\n${c}.is-open .store-cart {\n transform: translateX(0);\n}\n\n${c} .cart-card {\n height: 100%;\n display: flex;\n flex-direction: column;\n gap: 1rem;\n padding: 1rem;\n border-radius: 1.5rem;\n border: 1px solid var(--store-border);\n background: linear-gradient(180deg, var(--store-surface) 0%, var(--store-surface-alt) 100%);\n box-shadow:\n 0 18px 40px rgb(11 27 46 / 0.16),\n 0 4px 12px rgb(11 27 46 / 0.08);\n}\n\n${c} .cart-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n}\n\n${c} .cart-close {\n min-width: 2.1rem;\n height: 2.1rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n padding: 0;\n border-radius: 0.6rem;\n border: 1px solid var(--store-border-strong);\n background: rgb(255 255 255 / 0.78);\n color: var(--store-accent);\n font-size: 0.92rem;\n font-weight: 800;\n line-height: 1;\n box-shadow: 0 6px 16px rgb(18 54 93 / 0.08);\n}\n\n${c} .cart-close:hover {\n background: var(--store-accent-soft);\n border-color: rgb(18 54 93 / 0.24);\n color: var(--store-accent);\n}\n\n${c} .cart-close:focus-visible {\n outline: 2px solid rgb(18 54 93 / 0.25);\n}\n\n${c} .cart-status,\n${c} .cart-kpi-card,\n${c} .cart-line {\n border-radius: 0.95rem;\n background: rgb(255 255 255 / 0.58);\n border: 1px solid var(--store-border);\n}\n\n${c} .cart-status,\n${c} .cart-kpi-card,\n${c} .cart-line {\n padding: 0.95rem;\n}\n\n${c} .cart-kpi {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 0.75rem;\n}\n\n${c} .kpi-label {\n display: block;\n margin-bottom: 0.3rem;\n font-size: 0.68rem;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n font-weight: 700;\n color: var(--store-text-subtle);\n}\n\n${c} .kpi-value {\n font-size: 1rem;\n font-weight: 700;\n}\n\n${c} .cart-lines {\n flex: 1;\n min-height: 0;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n overflow-y: auto;\n overflow-x: hidden;\n scrollbar-gutter: stable;\n scrollbar-width: auto;\n scrollbar-color: rgb(120 136 155 / 0.9) rgb(255 255 255 / 0.55);\n}\n\n${c} .cart-lines::-webkit-scrollbar {\n width: 12px;\n}\n\n${c} .cart-lines::-webkit-scrollbar-track {\n background: rgb(255 255 255 / 0.55);\n border-radius: 999px;\n}\n\n${c} .cart-lines::-webkit-scrollbar-thumb {\n background: rgb(120 136 155 / 0.9);\n border-radius: 999px;\n border: 2px solid rgb(255 255 255 / 0.55);\n}\n\n${c} .cart-line {\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n}\n\n${c} .cart-line-copy {\n min-width: 0;\n display: grid;\n gap: 0.18rem;\n}\n\n${c} .cart-line-top,\n${c} .cart-line-controls,\n${c} .summary-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.75rem;\n}\n\n${c} .cart-line-title {\n font-size: 0.92rem;\n font-weight: 700;\n line-height: 1.32;\n overflow-wrap: anywhere;\n word-break: break-word;\n}\n\n${c} .qty-controls {\n display: inline-flex;\n align-items: center;\n gap: 0.45rem;\n}\n\n${c} .qty-badge {\n min-width: 1.9rem;\n text-align: center;\n font-weight: 700;\n}\n\n${c} .qty-btn,\n${c} .remove-btn {\n min-width: 2rem;\n height: 2rem;\n padding: 0 0.65rem;\n}\n\n${c} .cart-summary {\n padding-top: 0.25rem;\n border-top: 1px solid var(--store-accent-line);\n display: grid;\n gap: 0.7rem;\n}\n\n${c} .payment-source-field {\n display: grid;\n gap: 0.65rem;\n}\n\n${c} .payment-source-select {\n width: 100%;\n min-height: 2.9rem;\n padding: 0 0.95rem;\n border-radius: 0.8rem;\n border: 1px solid var(--store-border);\n background: rgb(255 255 255 / 0.78);\n color: var(--store-text-main);\n}\n\n${c} .payment-source-meta,\n${c} .payment-source-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 0.75rem;\n}\n\n${c} .payment-source-meta {\n padding: 0.85rem 0.9rem;\n border-radius: 0.95rem;\n border: 1px solid var(--store-border);\n background: rgb(255 255 255 / 0.44);\n}\n\n${c} .payment-source-detail {\n margin: 0.2rem 0 0;\n font-size: 0.82rem;\n line-height: 1.4;\n color: var(--store-text-muted);\n}\n\n${c} .payment-source-label {\n font-weight: 700;\n color: var(--store-text-main);\n}\n\n${c} .payment-source-balance {\n font-weight: 700;\n color: var(--store-success);\n}\n\n${c} .payment-source-state {\n font-size: 0.7rem;\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--store-text-subtle);\n}\n\n${c} .summary-row.total {\n font-size: 1rem;\n font-weight: 700;\n}\n\n${c} .summary-label,\n${c} .cart-line-meta {\n color: var(--store-text-muted);\n}\n\n${c} .summary-value {\n font-weight: 700;\n}\n\n${c} .summary-actions {\n display: grid;\n gap: 0.65rem;\n}\n\n${c} .cart-empty {\n padding: 1rem;\n border-radius: 0.95rem;\n border: 1px dashed var(--store-border);\n color: var(--store-text-muted);\n background: rgb(255 255 255 / 0.38);\n}\n\n@media (max-width: 1120px) {\n ${c} .store-cart {\n top: 0;\n right: 0;\n bottom: 0;\n width: min(24rem, 100%);\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Cart=function(){const e=a.getStoreState(r),c=a.summarizeCart(e.cartItems),d=a.getPaymentSources(s),m=a.getPaymentSourceById(s,e.selectedPaymentSource)||d[0]||null,u=d.filter(e=>!1!==e.enabled).length,g=m?m.label:"Unavailable",p=m?Number(m.balance||0):0,b=Math.max(0,p-c.total);return n("storefront-cart",l),t("div",{className:e.cartOpen?"is-open":"",[i]:"","aria-hidden":e.cartOpen?"false":"true"},t("aside",{className:"store-cart"},t("section",{className:"cart-card"},t("div",{className:"cart-header"},t("div",null,t("span",{className:"eyebrow"},"Cart"),t("h2",{className:"section-title"},"Acquisition Queue")),t("button",{type:"button",className:"cart-close","aria-label":"Close cart",title:"Close cart",onClick:()=>o.closeCart()},"X")),t("div",{className:"cart-kpi"},t("div",{className:"cart-kpi-card"},t("span",{className:"kpi-label"},"Items"),t("span",{className:"kpi-value"},c.lineCount)),t("div",{className:"cart-kpi-card"},t("span",{className:"kpi-label"},"Payment"),t("span",{className:"kpi-value"},g))),t("div",{className:"cart-status"},t("span",{className:"eyebrow"},"Payment Source"),t("div",{className:"payment-source-field"},t("select",{className:"payment-source-select",value:e.selectedPaymentSource,onChange:e=>o.selectPaymentSource(e.target.value)},d.map(e=>t("option",{value:e.id,disabled:!1===e.enabled},!1===e.enabled?`${e.label} (Locked)`:e.label))),m?t("div",{className:"payment-source-meta"},t("div",null,t("div",{className:"payment-source-row"},t("span",{className:"payment-source-label"},m.label),t("span",{className:"payment-source-balance"},a.formatCurrency(m.balance))),t("p",{className:"payment-source-detail"},m.detail)),t("span",{className:"payment-source-state"},u>0?!1===m.enabled?"Locked":"Available":"Unavailable")):null)),t("div",{className:"cart-lines","data-preserve-scroll-id":"cart-lines"},c.lineCount>0?e.cartItems.map(e=>t("div",{className:"cart-line"},t("div",{className:"cart-line-top"},t("div",{className:"cart-line-copy"},t("div",{className:"cart-line-title"},e.name)),t("strong",null,a.formatCurrency(a.parsePrice(e.price)*e.quantity))),t("div",{className:"cart-line-controls"},t("div",{className:"qty-controls"},t("button",{type:"button",className:"store-btn store-btn-secondary qty-btn",onClick:()=>o.decrementCartItem(e.code)},"-"),t("span",{className:"qty-badge"},e.quantity),t("button",{type:"button",className:"store-btn store-btn-secondary qty-btn",onClick:()=>o.incrementCartItem(e.code)},"+")),t("button",{type:"button",className:"store-btn store-btn-secondary remove-btn",onClick:()=>o.removeCartItem(e.code)},"Remove")))):t("div",{className:"cart-empty"},"No items are queued yet. Add products from the catalog to build a checkout payload.")),t("div",{className:"cart-summary"},t("div",{className:"summary-row"},t("span",{className:"summary-label"},"Items"),t("span",{className:"summary-value"},c.itemCount)),t("div",{className:"summary-row"},t("span",{className:"summary-label"},"Subtotal"),t("span",{className:"summary-value"},a.formatCurrency(c.subtotal))),t("div",{className:"summary-row"},t("span",{className:"summary-label"},"Remaining Source"),t("span",{className:"summary-value"},a.formatCurrency(b))),t("div",{className:"summary-row total"},t("span",{className:"summary-label"},"Total"),t("span",{className:"summary-value"},a.formatCurrency(c.total)))),t("div",{className:"summary-actions"},t("button",{type:"button",className:"store-btn store-btn-primary",disabled:0===c.lineCount||e.isCheckingOut,onClick:()=>o.requestCheckout()},e.isCheckingOut?"Submitting Request...":"Submit Checkout")))))}}(),function(){const e=window.StorefrontApp=window.StorefrontApp||{},{h:t,ensureScopedStyle:n}=e.runtime,r=e.getters,a=e.store,o=e.actions,s="data-ui-store-navbar",i=`[${s}]`,c=`\n${i} {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 1rem;\n padding: 0.9rem 1rem;\n margin-bottom: 0.95rem;\n border-bottom: 1px solid var(--store-accent-line);\n background:\n linear-gradient(180deg, rgb(255 255 255 / 0.52) 0%, transparent 100%),\n linear-gradient(180deg, rgb(236 241 246 / 0.52) 0%, rgb(245 243 239 / 0.2) 100%);\n}\n\n${i} .store-breadcrumbs {\n display: flex;\n align-items: center;\n gap: 0.55rem;\n min-width: 0;\n flex-wrap: wrap;\n}\n\n${i} .breadcrumb-link,\n${i} .breadcrumb-current,\n${i} .breadcrumb-separator {\n font-size: 0.78rem;\n letter-spacing: 0.1em;\n text-transform: uppercase;\n font-weight: 700;\n}\n\n${i} .breadcrumb-link {\n padding: 0;\n border: 0;\n background: transparent;\n color: var(--store-text-subtle);\n}\n\n${i} .breadcrumb-link:hover {\n color: var(--store-accent);\n}\n\n${i} .breadcrumb-current {\n color: var(--store-accent);\n}\n\n${i} .breadcrumb-separator {\n color: rgb(124 138 155 / 0.72);\n}\n\n${i} .store-cart-btn {\n position: relative;\n width: 2.6rem;\n height: 2.6rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n border-radius: 0.7rem;\n border: 1px solid var(--store-border-strong);\n background: rgb(255 255 255 / 0.68);\n color: var(--store-accent);\n box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.75);\n}\n\n${i} .store-cart-btn:hover {\n background: rgb(219 231 243 / 0.88);\n}\n\n${i} .cart-toggle-icon {\n position: relative;\n width: 0.95rem;\n height: 0.8rem;\n border: 1.5px solid currentColor;\n border-radius: 0.16rem 0.16rem 0.24rem 0.24rem;\n}\n\n${i} .cart-toggle-icon::before {\n content: "";\n position: absolute;\n top: -0.34rem;\n left: 0.2rem;\n width: 0.5rem;\n height: 0.3rem;\n border: 1.5px solid currentColor;\n border-bottom: 0;\n border-radius: 0.35rem 0.35rem 0 0;\n}\n\n${i} .cart-count {\n position: absolute;\n top: -0.35rem;\n right: -0.35rem;\n min-width: 1.25rem;\n height: 1.25rem;\n padding: 0 0.3rem;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n border-radius: 999px;\n background: var(--store-accent);\n color: #fff;\n font-size: 0.68rem;\n font-weight: 700;\n}\n\n@media (max-width: 1120px) {\n ${i} {\n align-items: flex-start;\n }\n}\n`;e.componentFns=e.componentFns||{},e.componentFns.Navbar=function(){const e=r.getStoreState(a),i=r.getStoreBreadcrumbs(e),l=r.summarizeCart(e.cartItems);return n("storefront-navbar",c),t("nav",{[s]:""},t("div",{className:"store-breadcrumbs","aria-label":"Store navigation"},i.map((e,n)=>n===i.length-1?t("span",{className:"breadcrumb-current"},e.label):[t("button",{type:"button",className:"breadcrumb-link",onClick:()=>o.navigateToBreadcrumb(e.id)},e.label),t("span",{className:"breadcrumb-separator"},"/")])),t("button",{type:"button",className:"store-cart-btn",onClick:()=>o.toggleCart(),title:e.cartOpen?"Close cart":"Open cart","aria-label":e.cartOpen?"Close cart":"Open cart"},t("span",{className:"cart-toggle-icon","aria-hidden":"true"}),l.itemCount>0?t("span",{className:"cart-count"},l.itemCount):null))}}(),function(){const e=window.ForgeWebUI,t=window.StorefrontApp;e.createApp({name:"store",root:"#app",setup({root:n}){e.mount(n,()=>t.components.App(),{preserveScroll:!1}),t.bridge&&t.bridge.notifyReady()}}).start()}(); \ No newline at end of file diff --git a/arma/client/addons/store/ui/src/bootstrap.js b/arma/client/addons/store/ui/src/bootstrap.js new file mode 100644 index 0000000..fe6082f --- /dev/null +++ b/arma/client/addons/store/ui/src/bootstrap.js @@ -0,0 +1,19 @@ +(function () { + const ForgeWebUI = window.ForgeWebUI; + const StorefrontApp = window.StorefrontApp; + const app = ForgeWebUI.createApp({ + name: "store", + root: "#app", + setup({ root }) { + ForgeWebUI.mount(root, () => StorefrontApp.components.App(), { + preserveScroll: false, + }); + + if (StorefrontApp.bridge) { + StorefrontApp.bridge.notifyReady(); + } + }, + }); + + app.start(); +})(); diff --git a/arma/client/addons/store/ui/src/bridge.js b/arma/client/addons/store/ui/src/bridge.js new file mode 100644 index 0000000..a38aa92 --- /dev/null +++ b/arma/client/addons/store/ui/src/bridge.js @@ -0,0 +1,81 @@ +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const store = StorefrontApp.store; + const bridge = window.ForgeWebUI.createBridge({ + closeEvent: "store::close", + globalName: "StoreUIBridge", + readyEvent: "store::ready", + }); + + function requestClose() { + return bridge.close({}); + } + + function requestCheckout(payload) { + return bridge.send("store::checkout::request", payload); + } + + function requestCategory(payload) { + return bridge.send("store::category::request", payload); + } + + function notifyReady() { + return bridge.ready({ loaded: true }); + } + + bridge.on("store::hydrate", (payloadData) => { + StorefrontApp.data.applyHydratePayload(payloadData); + store.hydrateFromPayload(payloadData); + }); + + bridge.on("store::config::hydrate", (payloadData) => { + StorefrontApp.data.applyHydratePayload(payloadData); + store.hydrateStoreConfig(payloadData); + }); + + bridge.on("store::checkout::success", (payloadData) => { + store.setIsCheckingOut(false); + store.setCartItems([]); + store.setCartOpen(false); + if (StorefrontApp.actions) { + StorefrontApp.actions.showNotice( + "success", + payloadData.message || "Checkout completed.", + ); + } + }); + + bridge.on("store::category::hydrate", (payloadData) => { + store.hydrateCategoryItems(payloadData); + }); + + bridge.on("store::category::failure", (payloadData) => { + store.finishCategoryRequest(payloadData.category || ""); + if (StorefrontApp.actions) { + StorefrontApp.actions.showNotice( + "error", + payloadData.message || "Category request failed.", + ); + } + }); + + bridge.on("store::checkout::failure", (payloadData) => { + store.setIsCheckingOut(false); + if (StorefrontApp.actions) { + StorefrontApp.actions.showNotice( + "error", + payloadData.message || "Checkout failed.", + ); + } + }); + + StorefrontApp.bridge = { + close: bridge.close, + requestClose, + requestCheckout, + requestCategory, + notifyReady, + receive: bridge.receive, + sendEvent: bridge.send, + }; +})(); diff --git a/arma/client/addons/store/ui/src/components/AppShell.js b/arma/client/addons/store/ui/src/components/AppShell.js new file mode 100644 index 0000000..810a7e5 --- /dev/null +++ b/arma/client/addons/store/ui/src/components/AppShell.js @@ -0,0 +1,669 @@ +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const { h, ensureScopedStyle } = StorefrontApp.runtime; + const WindowTitleBar = window.SharedUI.componentFns.WindowTitleBar; + const store = StorefrontApp.store; + const getters = StorefrontApp.getters; + const actions = StorefrontApp.actions; + const { catalog, session, storeConfig } = StorefrontApp.data; + const scopeAttr = "data-ui-store-app-shell"; + const scopeSelector = `[${scopeAttr}]`; + const appShellCss = ` +${scopeSelector} { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; + background: var(--store-shell-bg); +} + +${scopeSelector} .footer-title, +${scopeSelector} .eyebrow { + font-size: 0.68rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--store-text-subtle); + font-weight: 700; +} + +${scopeSelector} .module-header, +${scopeSelector} .store-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +${scopeSelector} .store-app { + flex: 1; + min-height: 0; + width: min(100%, 1613px); + margin: 0 auto; + display: grid; + grid-template-columns: 308px minmax(0, 1fr); + gap: 1.25rem; + padding: 1.25rem; +} + +${scopeSelector} .store-sidebar, +${scopeSelector} .store-main { + min-height: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +${scopeSelector} .store-main { + position: relative; + overflow: hidden; +} + +${scopeSelector} .module-card, +${scopeSelector} .store-panel { + background: linear-gradient(180deg, var(--store-surface) 0%, var(--store-surface-alt) 100%); + border: 1px solid var(--store-border); + border-radius: 1.35rem; +} + +${scopeSelector} .module-card { + padding: 1rem; +} + +${scopeSelector} .store-panel { + min-height: 0; + flex: 1 1 auto; + display: flex; + flex-direction: column; + width: min(100%, 1280px); + overflow: hidden; +} + +${scopeSelector} .module-header { + margin-bottom: 0.85rem; +} + +${scopeSelector} .store-panel-header { + padding: 1rem 1rem 0; +} + +${scopeSelector} .section-title { + margin: 0; + font-size: 1.1rem; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--store-text-main); +} + +${scopeSelector} .section-copy, +${scopeSelector} .footer-copy { + margin: 0.2rem 0 0; + font-size: 0.9rem; + line-height: 1.45; + color: var(--store-text-muted); +} + +${scopeSelector} .pill { + padding: 0.48rem 0.8rem; + border-radius: 999px; + background: var(--store-accent-soft); + color: var(--store-accent); + font-size: 0.74rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +${scopeSelector} .search-module { + display: flex; + flex-direction: column; + gap: 0.8rem; +} + +${scopeSelector} .search-form { + display: grid; + gap: 0.7rem; +} + +${scopeSelector} .search-input { + width: 100%; + height: 2.9rem; + padding: 0 0.95rem; + border-radius: 0.8rem; + border: 1px solid var(--store-border); + background: rgb(255 255 255 / 0.75); + color: var(--store-text-main); +} + +${scopeSelector} .quick-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +${scopeSelector} .quick-tag { + padding: 0.55rem 0.72rem; + border-radius: 999px; + border: 1px solid var(--store-border); + background: rgb(255 255 255 / 0.52); + color: var(--store-text-muted); + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +${scopeSelector} .filter-stack { + display: grid; + gap: 0.85rem; +} + +${scopeSelector} .filter-group { + padding: 0.95rem; + border-radius: 0.8rem; + background: rgb(255 255 255 / 0.48); + border: 1px solid var(--store-border); +} + +${scopeSelector} .filter-label { + display: block; + margin-bottom: 0.55rem; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--store-text-subtle); + font-weight: 700; +} + +${scopeSelector} .filter-value { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + color: var(--store-text-main); + font-size: 0.92rem; + font-weight: 600; +} + +${scopeSelector} .filter-placeholder { + color: var(--store-text-muted); + font-weight: 500; +} + +${scopeSelector} .store-panel-intro { + padding: 0 1rem 1rem; + border-bottom: 1px solid var(--store-accent-line); +} + +${scopeSelector} .store-footer-bar { + width: 100%; + border-top: 1px solid rgb(18 54 93 / 0.1); + background: transparent; +} + +${scopeSelector} .store-footer { + width: min(100%, 1613px); + margin: 0 auto; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + padding: 0.95rem 1.25rem 1.15rem; +} + +${scopeSelector} .footer-block { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +${scopeSelector} .store-toast-stack { + position: fixed; + top: 1.2rem; + right: 1.5rem; + z-index: 10; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +${scopeSelector} .store-toast { + max-width: 24rem; + padding: 0.85rem 1rem; + border-radius: 0.9rem; + border: 1px solid var(--store-border); + background: #fff; + box-shadow: 0 14px 28px rgb(16 34 56 / 0.14); + font-size: 0.92rem; +} + +${scopeSelector} .store-toast.is-success { + background: #ecfdf5; + border-color: #bbf7d0; + color: #166534; +} + +${scopeSelector} .store-toast.is-error { + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; +} + +@media (max-width: 1440px) { + ${scopeSelector} .store-app { + grid-template-columns: 284px minmax(0, 1fr); + } +} + +@media (max-width: 1120px) { + ${scopeSelector} .store-app { + grid-template-columns: 1fr; + overflow: auto; + } + + ${scopeSelector} .store-sidebar, + ${scopeSelector} .store-main { + min-height: auto; + } + + ${scopeSelector} .store-main { + overflow: visible; + } + + ${scopeSelector} .store-footer { + grid-template-columns: 1fr; + } + + ${scopeSelector} .store-toast-stack { + right: 1rem; + left: 1rem; + } +} +`; + + StorefrontApp.components = StorefrontApp.components || {}; + StorefrontApp.componentFns = StorefrontApp.componentFns || {}; + + function renderStoreBody(state) { + const { + CategoryCard, + SubcategoryCard, + ProductCard, + EmptyStateCard, + CategoryGrid, + SubcategoryGrid, + ProductGrid, + CatalogPager, + } = StorefrontApp.componentFns; + + if (state.view === "weapons" || state.view === "vehicles") { + const slotType = state.view === "vehicles" ? "vehicle" : "weapon"; + const items = getters.getVisibleSubcategoryCards(state, catalog); + + return SubcategoryGrid( + items.length > 0 + ? items.map((category) => + SubcategoryCard(category, slotType), + ) + : EmptyStateCard({ + title: "No matching slots", + copy: "Try a different search query or clear the current filter.", + actionLabel: "Clear Search", + onAction: () => actions.clearSearch(), + }), + ); + } + + if (state.view === "items") { + const items = getters.getVisibleItems(state, catalog); + const pagedItems = getters.getVisibleItemsPage(state, catalog); + const pagination = getters.getCatalogPagination(state, catalog); + const quantityByCode = state.cartItems.reduce((acc, item) => { + acc[item.code] = item.quantity; + return acc; + }, {}); + const selectionKey = String( + getters.getSelectionKey(state) || "", + ).toLowerCase(); + + return [ + ProductGrid( + state.isCatalogLoading && + state.catalogRequestKey === selectionKey && + items.length === 0 + ? EmptyStateCard({ + title: "Loading inventory", + copy: "Pulling live category items from the game engine.", + }) + : items.length > 0 + ? pagedItems.map((item) => + ProductCard( + item, + quantityByCode[item.code] || 0, + ), + ) + : EmptyStateCard({ + title: "No category items", + copy: state.searchQuery + ? "Your search filter excluded the live inventory returned for this category." + : "The game engine did not return any items for this category yet.", + actionLabel: "Clear Search", + onAction: () => actions.clearSearch(), + }), + ), + items.length > 0 ? CatalogPager(pagination) : null, + ]; + } + + const items = getters.getVisibleCategoryCards(state, catalog); + return CategoryGrid( + items.length > 0 + ? items.map((category) => CategoryCard(category)) + : EmptyStateCard({ + title: "No matching departments", + copy: "Your search filter excluded every top-level department.", + actionLabel: "Clear Search", + onAction: () => actions.clearSearch(), + }), + ); + } + + StorefrontApp.components.App = function App() { + const Navbar = StorefrontApp.componentFns.Navbar; + const Cart = StorefrontApp.componentFns.Cart; + const state = getters.getStoreState(store); + const header = getters.getStoreHeader(state); + const notice = store.getNotice(); + const activeQuery = state.searchQuery; + const paymentSources = getters.getPaymentSources(storeConfig); + const availablePaymentSourceCount = paymentSources.filter( + (source) => source.enabled !== false, + ).length; + const filterDepartment = + state.view === "items" + ? actions.formatTitle( + getters.getSelectionKey(state) || "Catalog", + ) + : actions.formatTitle(state.view); + const selectedPaymentSource = + getters.getPaymentSourceById( + storeConfig, + state.selectedPaymentSource, + ) || null; + + ensureScopedStyle("storefront-app-shell", appShellCss); + + return h( + "div", + { [scopeAttr]: "" }, + WindowTitleBar({ + kicker: "FORGE Logistics", + title: "Supply Exchange", + onClose: () => actions.closeStore(), + closeLabel: "Close store interface", + }), + notice.text + ? h( + "div", + { className: "store-toast-stack" }, + h( + "div", + { + className: + notice.type === "error" + ? "store-toast is-error" + : "store-toast is-success", + }, + notice.text, + ), + ) + : null, + h( + "div", + { className: "store-app" }, + h( + "aside", + { className: "store-sidebar" }, + h( + "section", + { className: "module-card search-module" }, + h( + "div", + { className: "module-header" }, + h( + "div", + null, + h("span", { className: "eyebrow" }, "Search"), + h( + "h2", + { className: "section-title" }, + "Inventory Search", + ), + ), + h("span", { className: "pill" }, "Live"), + ), + h( + "div", + { className: "search-form" }, + h("input", { + id: "store-search-input", + type: "text", + className: "search-input", + placeholder: + "Search inventory, classes, or suppliers", + value: activeQuery, + }), + h( + "div", + { + style: { + display: "flex", + gap: "0.65rem", + }, + }, + h( + "button", + { + type: "button", + className: + "store-btn store-btn-primary", + onClick: () => + actions.applySearchQuery( + document.getElementById( + "store-search-input", + )?.value || "", + ), + }, + "Apply Search", + ), + h( + "button", + { + type: "button", + className: + "store-btn store-btn-secondary", + onClick: () => actions.clearSearch(), + }, + "Clear", + ), + ), + ), + h( + "div", + { className: "quick-tags" }, + (storeConfig.searchTags || []).map((tag) => + h("span", { className: "quick-tag" }, tag), + ), + ), + ), + h( + "section", + { className: "module-card" }, + h( + "div", + { className: "module-header" }, + h( + "div", + null, + h("span", { className: "eyebrow" }, "Filter"), + h( + "h2", + { className: "section-title" }, + "Procurement Filters", + ), + ), + h( + "span", + { className: "pill" }, + storeConfig.moduleState, + ), + ), + h( + "div", + { className: "filter-stack" }, + h( + "div", + { className: "filter-group" }, + h( + "span", + { className: "filter-label" }, + "Department", + ), + h( + "div", + { className: "filter-value" }, + h( + "span", + { className: "filter-placeholder" }, + filterDepartment, + ), + ), + ), + h( + "div", + { className: "filter-group" }, + h( + "span", + { className: "filter-label" }, + "Availability", + ), + h( + "div", + { className: "filter-value" }, + h( + "span", + { className: "filter-placeholder" }, + storeConfig.availability, + ), + ), + ), + h( + "div", + { className: "filter-group" }, + h( + "span", + { className: "filter-label" }, + "Payment", + ), + h( + "div", + { className: "filter-value" }, + h( + "span", + { className: "filter-placeholder" }, + selectedPaymentSource + ? selectedPaymentSource.label + : "Cash", + ), + ), + ), + ), + ), + ), + h( + "main", + { className: "store-main" }, + h( + "section", + { className: "store-panel" }, + Navbar(), + h( + "div", + { className: "store-panel-header" }, + h( + "div", + null, + h( + "span", + { className: "eyebrow" }, + header.eyebrow, + ), + h( + "h1", + { className: "section-title" }, + header.title, + ), + ), + h("span", { className: "pill" }, header.badge), + ), + h( + "div", + { className: "store-panel-intro" }, + h("p", { className: "section-copy" }, header.copy), + ), + renderStoreBody(state), + ), + Cart(), + ), + ), + h( + "footer", + { className: "store-footer-bar" }, + h( + "div", + { className: "store-footer" }, + h( + "div", + { className: "footer-block" }, + h( + "span", + { className: "footer-title" }, + "Procurement Desk", + ), + h( + "span", + { className: "footer-copy" }, + "Authorized supply browsing for personnel loadout preparation and mission staging.", + ), + ), + h( + "div", + { className: "footer-block" }, + h( + "span", + { className: "footer-title" }, + "Catalog Scope", + ), + h( + "span", + { className: "footer-copy" }, + "Uniforms, protective gear, weapon slots, vehicles, ammunition groups, and general support inventory.", + ), + ), + h( + "div", + { className: "footer-block" }, + h( + "span", + { className: "footer-title" }, + "Purchase Access", + ), + h( + "span", + { className: "footer-copy" }, + `${session.approval} approval. ${availablePaymentSourceCount} payment source(s) currently available${session.orgName ? ` for ${session.orgName}.` : "."}`, + ), + ), + ), + ), + ); + }; +})(); diff --git a/arma/client/addons/store/ui/src/components/cards.js b/arma/client/addons/store/ui/src/components/cards.js new file mode 100644 index 0000000..fecf0cd --- /dev/null +++ b/arma/client/addons/store/ui/src/components/cards.js @@ -0,0 +1,546 @@ +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const { h, ensureScopedStyle } = StorefrontApp.runtime; + const actions = StorefrontApp.actions; + const media = StorefrontApp.media; + const scopeAttr = "data-ui-store-cards"; + const scopeSelector = `[${scopeAttr}]`; + const cardsCss = ` +${scopeSelector}.catalog-grid-shell { + flex: 1; + min-height: 0; + display: flex; +} + +${scopeSelector}.catalog-pager-shell { + display: block; +} + +${scopeSelector} .catalog-grid { + flex: 1; + min-height: 0; + width: 100%; + padding: 1rem; + display: grid; + gap: 1rem; + align-content: start; + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; + scrollbar-width: auto; + scrollbar-color: rgb(120 136 155 / 0.9) rgb(255 255 255 / 0.45); +} + +${scopeSelector} .catalog-grid::-webkit-scrollbar { + width: 12px; +} + +${scopeSelector} .catalog-grid::-webkit-scrollbar-track { + background: rgb(255 255 255 / 0.45); + border-radius: 999px; +} + +${scopeSelector} .catalog-grid::-webkit-scrollbar-thumb { + background: rgb(120 136 155 / 0.9); + border-radius: 999px; + border: 2px solid rgb(255 255 255 / 0.45); +} + +${scopeSelector} .catalog-grid.is-categories, +${scopeSelector} .catalog-grid.is-products { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +${scopeSelector} .catalog-grid.is-subcategories { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +${scopeSelector} .card-button, +${scopeSelector} .product-card, +${scopeSelector} .empty-state { + border: 1px solid var(--store-border); + border-radius: 1.15rem; + background: + linear-gradient(180deg, rgb(255 255 255 / 0.72) 0%, rgb(226 233 239 / 0.9) 100%), + var(--store-surface-strong); + color: var(--store-accent); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / 0.8), + 0 10px 24px rgb(16 34 56 / 0.06); +} + +${scopeSelector} .card-button { + min-height: 12.5rem; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.75rem; + padding: 1.35rem; + text-align: left; + transition: + transform 120ms ease, + box-shadow 120ms ease, + border-color 120ms ease; +} + +${scopeSelector} .card-button:hover, +${scopeSelector} .product-card:hover { + transform: translateY(-2px); + border-color: rgb(18 54 93 / 0.32); + box-shadow: + 0 16px 28px rgb(16 34 56 / 0.11), + inset 0 1px 0 rgb(255 255 255 / 0.88); +} + +${scopeSelector} .card-kicker, +${scopeSelector} .product-code, +${scopeSelector} .empty-state-kicker { + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + font-weight: 700; + color: var(--store-text-subtle); +} + +${scopeSelector} .card-label { + font-size: 1.08rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +${scopeSelector} .card-copy, +${scopeSelector} .product-copy, +${scopeSelector} .empty-state-copy { + margin: 0; + color: var(--store-text-muted); + line-height: 1.45; +} + +${scopeSelector} .product-copy { + white-space: pre-line; +} + +${scopeSelector} .product-card { + min-height: 15.5rem; + padding: 0.8rem; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +${scopeSelector} .product-image { + height: 5.9rem; + border-radius: 0.95rem; + border: 1px dashed rgb(18 54 93 / 0.24); + background: linear-gradient(135deg, rgb(235 240 245) 0%, rgb(221 228 235) 100%); + display: flex; + align-items: center; + justify-content: center; + color: var(--store-text-subtle); + font-size: 0.78rem; + letter-spacing: 0.16em; + text-transform: uppercase; + overflow: hidden; +} + +${scopeSelector} .product-image-asset { + width: 100%; + height: 100%; + object-fit: contain; +} + +${scopeSelector} .product-meta { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +${scopeSelector} .product-name { + font-size: 0.96rem; + font-weight: 700; + color: var(--store-text-main); + line-height: 1.3; +} + +${scopeSelector} .product-footer { + margin-top: auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +${scopeSelector} .product-price { + font-size: 0.96rem; + font-weight: 700; + color: var(--store-success); +} + +${scopeSelector} .product-qty { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.85rem; + height: 1.85rem; + border-radius: 999px; + background: var(--store-accent-soft); + color: var(--store-accent); + font-size: 0.76rem; + font-weight: 700; +} + +${scopeSelector} .empty-state { + padding: 1.35rem; + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +${scopeSelector} .catalog-pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.9rem; + padding: 0.55rem 0.9rem 0.75rem; + border-top: 1px solid var(--store-accent-line); +} + +${scopeSelector} .catalog-pager-meta { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +${scopeSelector} .catalog-pager-summary { + font-size: 0.86rem; + color: var(--store-text-muted); +} + +${scopeSelector} .catalog-pager-actions { + display: inline-flex; + align-items: center; + gap: 0.6rem; +} + +${scopeSelector} .catalog-pager-page { + min-width: 5.75rem; + text-align: center; + font-size: 0.82rem; + font-weight: 700; + color: var(--store-accent); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +${scopeSelector} .product-copy { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +@media (max-width: 1440px) { + ${scopeSelector} .catalog-grid.is-categories, + ${scopeSelector} .catalog-grid.is-products { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 1120px) { + ${scopeSelector} .catalog-grid.is-categories, + ${scopeSelector} .catalog-grid.is-subcategories, + ${scopeSelector} .catalog-grid.is-products { + grid-template-columns: 1fr; + } +} +`; + + StorefrontApp.componentFns = StorefrontApp.componentFns || {}; + + function createGrid(className, children) { + ensureScopedStyle("storefront-cards", cardsCss); + + if ( + className === "is-products" && + media && + typeof media.scheduleTextureObservation === "function" + ) { + media.scheduleTextureObservation(); + } + + return h( + "div", + { + [scopeAttr]: "", + className: "catalog-grid-shell", + }, + h( + "div", + { + className: `catalog-grid ${className}`, + "data-preserve-scroll-id": "catalog-grid", + }, + children, + ), + ); + } + + function formatDescription(description, fallbackValue) { + const rawDescription = String(description || "").trim(); + if (!rawDescription) { + return fallbackValue; + } + + const htmlDescription = rawDescription + .replace(/<\s*br\s*\/?\s*>/gi, "\n") + .replace(/<\/\s*p\s*>/gi, "\n") + .replace(/<\s*li\s*>/gi, "- ") + .replace(/<\/\s*li\s*>/gi, "\n"); + const scratch = document.createElement("div"); + scratch.innerHTML = htmlDescription; + + const textDescription = String( + scratch.textContent || scratch.innerText || "", + ) + .replace(/\u00a0/g, " ") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + + return textDescription || fallbackValue; + } + + StorefrontApp.componentFns.CategoryCard = function CategoryCard(category) { + return h( + "button", + { + type: "button", + className: "card-button", + onClick: () => actions.selectCategory(category.id), + }, + h("span", { className: "card-kicker" }, "Department"), + h("strong", { className: "card-label" }, category.label), + h( + "p", + { className: "card-copy" }, + "Open this department and move into staged inventory browsing.", + ), + ); + }; + + StorefrontApp.componentFns.SubcategoryCard = function SubcategoryCard( + category, + slotType, + ) { + return h( + "button", + { + type: "button", + className: "card-button", + onClick: () => actions.selectSubcategory(category.id, slotType), + }, + h( + "span", + { className: "card-kicker" }, + slotType === "vehicle" ? "Vehicle Class" : "Weapon Slot", + ), + h("strong", { className: "card-label" }, category.label), + h( + "p", + { className: "card-copy" }, + "Open the next tier and review product previews for this selection.", + ), + ); + }; + + StorefrontApp.componentFns.ProductCard = function ProductCard( + item, + quantityInCart, + ) { + const textureState = + media && typeof media.getTextureState === "function" + ? media.getTextureState(item.image) + : { isVisible: true }; + const textureSource = + media && typeof media.getTextureSource === "function" + ? media.getTextureSource(item.image) + : ""; + const description = formatDescription( + item.description, + item.className || item.code, + ); + + return h( + "article", + { className: "product-card" }, + h( + "div", + { + className: "product-image", + "data-store-texture-path": item.image || "", + }, + textureSource + ? h("img", { + className: "product-image-asset", + src: textureSource, + alt: item.name, + loading: "lazy", + }) + : textureState.isVisible + ? "Loading Image" + : "Image Placeholder", + ), + h( + "div", + { className: "product-meta" }, + h( + "span", + { className: "product-code" }, + item.type || item.code || item.className, + ), + h("strong", { className: "product-name" }, item.name), + ), + h("p", { className: "product-copy" }, description), + h( + "div", + { className: "product-footer" }, + h( + "span", + { className: "product-price" }, + item.price || "Pending", + ), + h( + "div", + { + style: { + display: "flex", + alignItems: "center", + gap: "0.55rem", + }, + }, + quantityInCart > 0 + ? h( + "span", + { className: "product-qty" }, + quantityInCart, + ) + : null, + h( + "button", + { + type: "button", + className: "store-btn store-btn-primary", + onClick: () => actions.addToCart(item), + }, + "Add to Cart", + ), + ), + ), + ); + }; + + StorefrontApp.componentFns.EmptyStateCard = function EmptyStateCard({ + title, + copy, + actionLabel, + onAction, + }) { + return h( + "article", + { className: "empty-state" }, + h("span", { className: "empty-state-kicker" }, "No Results"), + h("strong", { className: "card-label" }, title), + h("p", { className: "empty-state-copy" }, copy), + actionLabel && typeof onAction === "function" + ? h( + "button", + { + type: "button", + className: "store-btn store-btn-secondary", + onClick: onAction, + }, + actionLabel, + ) + : null, + ); + }; + + StorefrontApp.componentFns.CategoryGrid = function CategoryGrid(children) { + return createGrid("is-categories", children); + }; + + StorefrontApp.componentFns.SubcategoryGrid = function SubcategoryGrid( + children, + ) { + return createGrid("is-subcategories", children); + }; + + StorefrontApp.componentFns.ProductGrid = function ProductGrid(children) { + return createGrid("is-products", children); + }; + + StorefrontApp.componentFns.CatalogPager = function CatalogPager({ + currentPage, + totalPages, + startIndex, + endIndex, + totalItems, + }) { + ensureScopedStyle("storefront-cards", cardsCss); + + return h( + "div", + { + [scopeAttr]: "", + className: "catalog-pager-shell", + }, + h( + "div", + { className: "catalog-pager" }, + h( + "div", + { className: "catalog-pager-meta" }, + h("span", { className: "card-kicker" }, "Catalog Page"), + h( + "span", + { className: "catalog-pager-summary" }, + totalItems > 0 + ? `Showing ${startIndex}-${endIndex} of ${totalItems} items` + : "No items available", + ), + ), + h( + "div", + { className: "catalog-pager-actions" }, + h( + "button", + { + type: "button", + className: "store-btn store-btn-secondary", + disabled: currentPage <= 1, + onClick: () => actions.goToPreviousCatalogPage(), + }, + "Previous", + ), + h( + "span", + { className: "catalog-pager-page" }, + `Page ${currentPage} / ${totalPages}`, + ), + h( + "button", + { + type: "button", + className: "store-btn store-btn-secondary", + disabled: currentPage >= totalPages, + onClick: () => + actions.goToNextCatalogPage(totalPages), + }, + "Next", + ), + ), + ), + ); + }; +})(); diff --git a/arma/client/addons/store/ui/src/components/cart.js b/arma/client/addons/store/ui/src/components/cart.js new file mode 100644 index 0000000..4249d59 --- /dev/null +++ b/arma/client/addons/store/ui/src/components/cart.js @@ -0,0 +1,645 @@ +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const { h, ensureScopedStyle } = StorefrontApp.runtime; + const store = StorefrontApp.store; + const getters = StorefrontApp.getters; + const actions = StorefrontApp.actions; + const { storeConfig } = StorefrontApp.data; + const scopeAttr = "data-ui-store-cart"; + const scopeSelector = `[${scopeAttr}]`; + const cartCss = ` +${scopeSelector} { + position: absolute; + inset: 0; + z-index: 4; + pointer-events: none; +} + +${scopeSelector}.is-open { + pointer-events: auto; +} + +${scopeSelector} .store-cart { + position: absolute; + top: 0.5rem; + right: 0.5rem; + bottom: 0.5rem; + width: min(24rem, calc(100% - 1rem)); + transform: translateX(calc(100% + 1rem)); + transition: transform 180ms ease; +} + +${scopeSelector}.is-open .store-cart { + transform: translateX(0); +} + +${scopeSelector} .cart-card { + height: 100%; + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + border-radius: 1.5rem; + border: 1px solid var(--store-border); + background: linear-gradient(180deg, var(--store-surface) 0%, var(--store-surface-alt) 100%); + box-shadow: + 0 18px 40px rgb(11 27 46 / 0.16), + 0 4px 12px rgb(11 27 46 / 0.08); +} + +${scopeSelector} .cart-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +${scopeSelector} .cart-close { + min-width: 2.1rem; + height: 2.1rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border-radius: 0.6rem; + border: 1px solid var(--store-border-strong); + background: rgb(255 255 255 / 0.78); + color: var(--store-accent); + font-size: 0.92rem; + font-weight: 800; + line-height: 1; + box-shadow: 0 6px 16px rgb(18 54 93 / 0.08); +} + +${scopeSelector} .cart-close:hover { + background: var(--store-accent-soft); + border-color: rgb(18 54 93 / 0.24); + color: var(--store-accent); +} + +${scopeSelector} .cart-close:focus-visible { + outline: 2px solid rgb(18 54 93 / 0.25); +} + +${scopeSelector} .cart-status, +${scopeSelector} .cart-kpi-card, +${scopeSelector} .cart-line { + border-radius: 0.95rem; + background: rgb(255 255 255 / 0.58); + border: 1px solid var(--store-border); +} + +${scopeSelector} .cart-status, +${scopeSelector} .cart-kpi-card, +${scopeSelector} .cart-line { + padding: 0.95rem; +} + +${scopeSelector} .cart-kpi { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; +} + +${scopeSelector} .kpi-label { + display: block; + margin-bottom: 0.3rem; + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; + font-weight: 700; + color: var(--store-text-subtle); +} + +${scopeSelector} .kpi-value { + font-size: 1rem; + font-weight: 700; +} + +${scopeSelector} .cart-lines { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; + scrollbar-width: auto; + scrollbar-color: rgb(120 136 155 / 0.9) rgb(255 255 255 / 0.55); +} + +${scopeSelector} .cart-lines::-webkit-scrollbar { + width: 12px; +} + +${scopeSelector} .cart-lines::-webkit-scrollbar-track { + background: rgb(255 255 255 / 0.55); + border-radius: 999px; +} + +${scopeSelector} .cart-lines::-webkit-scrollbar-thumb { + background: rgb(120 136 155 / 0.9); + border-radius: 999px; + border: 2px solid rgb(255 255 255 / 0.55); +} + +${scopeSelector} .cart-line { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +${scopeSelector} .cart-line-copy { + min-width: 0; + display: grid; + gap: 0.18rem; +} + +${scopeSelector} .cart-line-top, +${scopeSelector} .cart-line-controls, +${scopeSelector} .summary-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +${scopeSelector} .cart-line-title { + font-size: 0.92rem; + font-weight: 700; + line-height: 1.32; + overflow-wrap: anywhere; + word-break: break-word; +} + +${scopeSelector} .qty-controls { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +${scopeSelector} .qty-badge { + min-width: 1.9rem; + text-align: center; + font-weight: 700; +} + +${scopeSelector} .qty-btn, +${scopeSelector} .remove-btn { + min-width: 2rem; + height: 2rem; + padding: 0 0.65rem; +} + +${scopeSelector} .cart-summary { + padding-top: 0.25rem; + border-top: 1px solid var(--store-accent-line); + display: grid; + gap: 0.7rem; +} + +${scopeSelector} .payment-source-field { + display: grid; + gap: 0.65rem; +} + +${scopeSelector} .payment-source-select { + width: 100%; + min-height: 2.9rem; + padding: 0 0.95rem; + border-radius: 0.8rem; + border: 1px solid var(--store-border); + background: rgb(255 255 255 / 0.78); + color: var(--store-text-main); +} + +${scopeSelector} .payment-source-meta, +${scopeSelector} .payment-source-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +${scopeSelector} .payment-source-meta { + padding: 0.85rem 0.9rem; + border-radius: 0.95rem; + border: 1px solid var(--store-border); + background: rgb(255 255 255 / 0.44); +} + +${scopeSelector} .payment-source-detail { + margin: 0.2rem 0 0; + font-size: 0.82rem; + line-height: 1.4; + color: var(--store-text-muted); +} + +${scopeSelector} .payment-source-label { + font-weight: 700; + color: var(--store-text-main); +} + +${scopeSelector} .payment-source-balance { + font-weight: 700; + color: var(--store-success); +} + +${scopeSelector} .payment-source-state { + font-size: 0.7rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--store-text-subtle); +} + +${scopeSelector} .summary-row.total { + font-size: 1rem; + font-weight: 700; +} + +${scopeSelector} .summary-label, +${scopeSelector} .cart-line-meta { + color: var(--store-text-muted); +} + +${scopeSelector} .summary-value { + font-weight: 700; +} + +${scopeSelector} .summary-actions { + display: grid; + gap: 0.65rem; +} + +${scopeSelector} .cart-empty { + padding: 1rem; + border-radius: 0.95rem; + border: 1px dashed var(--store-border); + color: var(--store-text-muted); + background: rgb(255 255 255 / 0.38); +} + +@media (max-width: 1120px) { + ${scopeSelector} .store-cart { + top: 0; + right: 0; + bottom: 0; + width: min(24rem, 100%); + } +} +`; + + StorefrontApp.componentFns = StorefrontApp.componentFns || {}; + + StorefrontApp.componentFns.Cart = function Cart() { + const state = getters.getStoreState(store); + const summary = getters.summarizeCart(state.cartItems); + const paymentSources = getters.getPaymentSources(storeConfig); + const selectedPaymentSource = + getters.getPaymentSourceById( + storeConfig, + state.selectedPaymentSource, + ) || + paymentSources[0] || + null; + const availablePaymentSourceCount = paymentSources.filter( + (source) => source.enabled !== false, + ).length; + const selectedPaymentLabel = selectedPaymentSource + ? selectedPaymentSource.label + : "Unavailable"; + const selectedPaymentBalance = selectedPaymentSource + ? Number(selectedPaymentSource.balance || 0) + : 0; + const remainingSourceBalance = Math.max( + 0, + selectedPaymentBalance - summary.total, + ); + + ensureScopedStyle("storefront-cart", cartCss); + + return h( + "div", + { + className: state.cartOpen ? "is-open" : "", + [scopeAttr]: "", + "aria-hidden": state.cartOpen ? "false" : "true", + }, + h( + "aside", + { className: "store-cart" }, + h( + "section", + { className: "cart-card" }, + h( + "div", + { className: "cart-header" }, + h( + "div", + null, + h("span", { className: "eyebrow" }, "Cart"), + h( + "h2", + { className: "section-title" }, + "Acquisition Queue", + ), + ), + h( + "button", + { + type: "button", + className: "cart-close", + "aria-label": "Close cart", + title: "Close cart", + onClick: () => actions.closeCart(), + }, + "X", + ), + ), + h( + "div", + { className: "cart-kpi" }, + h( + "div", + { className: "cart-kpi-card" }, + h("span", { className: "kpi-label" }, "Items"), + h( + "span", + { className: "kpi-value" }, + summary.lineCount, + ), + ), + h( + "div", + { className: "cart-kpi-card" }, + h("span", { className: "kpi-label" }, "Payment"), + h( + "span", + { className: "kpi-value" }, + selectedPaymentLabel, + ), + ), + ), + h( + "div", + { className: "cart-status" }, + h("span", { className: "eyebrow" }, "Payment Source"), + h( + "div", + { className: "payment-source-field" }, + h( + "select", + { + className: "payment-source-select", + value: state.selectedPaymentSource, + onChange: (event) => + actions.selectPaymentSource( + event.target.value, + ), + }, + paymentSources.map((source) => + h( + "option", + { + value: source.id, + disabled: source.enabled === false, + }, + source.enabled === false + ? `${source.label} (Locked)` + : source.label, + ), + ), + ), + selectedPaymentSource + ? h( + "div", + { + className: "payment-source-meta", + }, + h( + "div", + null, + h( + "div", + { + className: + "payment-source-row", + }, + h( + "span", + { + className: + "payment-source-label", + }, + selectedPaymentSource.label, + ), + h( + "span", + { + className: + "payment-source-balance", + }, + getters.formatCurrency( + selectedPaymentSource.balance, + ), + ), + ), + h( + "p", + { + className: + "payment-source-detail", + }, + selectedPaymentSource.detail, + ), + ), + h( + "span", + { + className: "payment-source-state", + }, + availablePaymentSourceCount > 0 + ? selectedPaymentSource.enabled === + false + ? "Locked" + : "Available" + : "Unavailable", + ), + ) + : null, + ), + ), + h( + "div", + { + className: "cart-lines", + "data-preserve-scroll-id": "cart-lines", + }, + summary.lineCount > 0 + ? state.cartItems.map((item) => + h( + "div", + { className: "cart-line" }, + h( + "div", + { className: "cart-line-top" }, + h( + "div", + { + className: "cart-line-copy", + }, + h( + "div", + { + className: + "cart-line-title", + }, + item.name, + ), + ), + h( + "strong", + null, + getters.formatCurrency( + getters.parsePrice( + item.price, + ) * item.quantity, + ), + ), + ), + h( + "div", + { className: "cart-line-controls" }, + h( + "div", + { className: "qty-controls" }, + h( + "button", + { + type: "button", + className: + "store-btn store-btn-secondary qty-btn", + onClick: () => + actions.decrementCartItem( + item.code, + ), + }, + "-", + ), + h( + "span", + { className: "qty-badge" }, + item.quantity, + ), + h( + "button", + { + type: "button", + className: + "store-btn store-btn-secondary qty-btn", + onClick: () => + actions.incrementCartItem( + item.code, + ), + }, + "+", + ), + ), + h( + "button", + { + type: "button", + className: + "store-btn store-btn-secondary remove-btn", + onClick: () => + actions.removeCartItem( + item.code, + ), + }, + "Remove", + ), + ), + ), + ) + : h( + "div", + { className: "cart-empty" }, + "No items are queued yet. Add products from the catalog to build a checkout payload.", + ), + ), + h( + "div", + { className: "cart-summary" }, + h( + "div", + { className: "summary-row" }, + h("span", { className: "summary-label" }, "Items"), + h( + "span", + { className: "summary-value" }, + summary.itemCount, + ), + ), + h( + "div", + { className: "summary-row" }, + h( + "span", + { className: "summary-label" }, + "Subtotal", + ), + h( + "span", + { className: "summary-value" }, + getters.formatCurrency(summary.subtotal), + ), + ), + h( + "div", + { className: "summary-row" }, + h( + "span", + { className: "summary-label" }, + "Remaining Source", + ), + h( + "span", + { className: "summary-value" }, + getters.formatCurrency(remainingSourceBalance), + ), + ), + h( + "div", + { className: "summary-row total" }, + h("span", { className: "summary-label" }, "Total"), + h( + "span", + { className: "summary-value" }, + getters.formatCurrency(summary.total), + ), + ), + ), + h( + "div", + { className: "summary-actions" }, + h( + "button", + { + type: "button", + className: "store-btn store-btn-primary", + disabled: + summary.lineCount === 0 || + state.isCheckingOut, + onClick: () => actions.requestCheckout(), + }, + state.isCheckingOut + ? "Submitting Request..." + : "Submit Checkout", + ), + ), + ), + ), + ); + }; +})(); diff --git a/arma/client/addons/store/ui/src/components/navbar.js b/arma/client/addons/store/ui/src/components/navbar.js new file mode 100644 index 0000000..675ea48 --- /dev/null +++ b/arma/client/addons/store/ui/src/components/navbar.js @@ -0,0 +1,189 @@ +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const { h, ensureScopedStyle } = StorefrontApp.runtime; + const getters = StorefrontApp.getters; + const store = StorefrontApp.store; + const actions = StorefrontApp.actions; + const scopeAttr = "data-ui-store-navbar"; + const scopeSelector = `[${scopeAttr}]`; + const navbarCss = ` +${scopeSelector} { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem 1rem; + margin-bottom: 0.95rem; + border-bottom: 1px solid var(--store-accent-line); + background: + linear-gradient(180deg, rgb(255 255 255 / 0.52) 0%, transparent 100%), + linear-gradient(180deg, rgb(236 241 246 / 0.52) 0%, rgb(245 243 239 / 0.2) 100%); +} + +${scopeSelector} .store-breadcrumbs { + display: flex; + align-items: center; + gap: 0.55rem; + min-width: 0; + flex-wrap: wrap; +} + +${scopeSelector} .breadcrumb-link, +${scopeSelector} .breadcrumb-current, +${scopeSelector} .breadcrumb-separator { + font-size: 0.78rem; + letter-spacing: 0.1em; + text-transform: uppercase; + font-weight: 700; +} + +${scopeSelector} .breadcrumb-link { + padding: 0; + border: 0; + background: transparent; + color: var(--store-text-subtle); +} + +${scopeSelector} .breadcrumb-link:hover { + color: var(--store-accent); +} + +${scopeSelector} .breadcrumb-current { + color: var(--store-accent); +} + +${scopeSelector} .breadcrumb-separator { + color: rgb(124 138 155 / 0.72); +} + +${scopeSelector} .store-cart-btn { + position: relative; + width: 2.6rem; + height: 2.6rem; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + border-radius: 0.7rem; + border: 1px solid var(--store-border-strong); + background: rgb(255 255 255 / 0.68); + color: var(--store-accent); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.75); +} + +${scopeSelector} .store-cart-btn:hover { + background: rgb(219 231 243 / 0.88); +} + +${scopeSelector} .cart-toggle-icon { + position: relative; + width: 0.95rem; + height: 0.8rem; + border: 1.5px solid currentColor; + border-radius: 0.16rem 0.16rem 0.24rem 0.24rem; +} + +${scopeSelector} .cart-toggle-icon::before { + content: ""; + position: absolute; + top: -0.34rem; + left: 0.2rem; + width: 0.5rem; + height: 0.3rem; + border: 1.5px solid currentColor; + border-bottom: 0; + border-radius: 0.35rem 0.35rem 0 0; +} + +${scopeSelector} .cart-count { + position: absolute; + top: -0.35rem; + right: -0.35rem; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.3rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: var(--store-accent); + color: #fff; + font-size: 0.68rem; + font-weight: 700; +} + +@media (max-width: 1120px) { + ${scopeSelector} { + align-items: flex-start; + } +} +`; + + StorefrontApp.componentFns = StorefrontApp.componentFns || {}; + + StorefrontApp.componentFns.Navbar = function Navbar() { + const state = getters.getStoreState(store); + const items = getters.getStoreBreadcrumbs(state); + const cartSummary = getters.summarizeCart(state.cartItems); + + ensureScopedStyle("storefront-navbar", navbarCss); + + return h( + "nav", + { [scopeAttr]: "" }, + h( + "div", + { + className: "store-breadcrumbs", + "aria-label": "Store navigation", + }, + items.map((item, index) => { + const isCurrent = index === items.length - 1; + + if (isCurrent) { + return h( + "span", + { className: "breadcrumb-current" }, + item.label, + ); + } + + return [ + h( + "button", + { + type: "button", + className: "breadcrumb-link", + onClick: () => + actions.navigateToBreadcrumb(item.id), + }, + item.label, + ), + h("span", { className: "breadcrumb-separator" }, "/"), + ]; + }), + ), + h( + "button", + { + type: "button", + className: "store-cart-btn", + onClick: () => actions.toggleCart(), + title: state.cartOpen ? "Close cart" : "Open cart", + "aria-label": state.cartOpen ? "Close cart" : "Open cart", + }, + h("span", { + className: "cart-toggle-icon", + "aria-hidden": "true", + }), + cartSummary.itemCount > 0 + ? h( + "span", + { className: "cart-count" }, + cartSummary.itemCount, + ) + : null, + ), + ); + }; +})(); diff --git a/arma/client/addons/store/ui/src/data.js b/arma/client/addons/store/ui/src/data.js new file mode 100644 index 0000000..5132da7 --- /dev/null +++ b/arma/client/addons/store/ui/src/data.js @@ -0,0 +1,138 @@ +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + + const defaultSession = { + actorName: "", + actorUid: "", + approval: "Field Access", + orgId: "", + orgName: "", + orgLeader: false, + defaultOrgCeo: false, + canUseOrgFunds: false, + }; + + const defaultStoreConfig = { + budget: 50000, + creditLine: 0, + availability: "In-Stock", + moduleState: "Preview", + searchTags: [ + "Attachment", + "Grenade", + "Medical", + "Consumable", + "Static", + "Scope", + "Item", + "Misc", + ], + paymentSources: [ + { + id: "cash", + label: "Cash", + balance: 0, + enabled: false, + detail: "Use on-hand cash carried by the player.", + }, + { + id: "bank", + label: "Bank", + balance: 0, + enabled: false, + detail: "Charge the player bank account.", + }, + { + id: "org_funds", + label: "Org Funds", + balance: 0, + enabled: false, + detail: "Only organization leaders or the default-org CEO can use treasury funds.", + }, + { + id: "credit_line", + label: "Credit Line", + balance: 0, + enabled: false, + detail: "No approved credit line is assigned to this member.", + }, + ], + defaultPaymentSource: "cash", + }; + + function cloneValue(value) { + return JSON.parse(JSON.stringify(value)); + } + + function replaceObject(target, source) { + Object.keys(target).forEach((key) => delete target[key]); + Object.assign(target, cloneValue(source)); + } + + const catalog = { + categoryCards: [ + { id: "uniforms", label: "Uniforms" }, + { id: "headgear", label: "Headgear" }, + { id: "facewear", label: "Facewear" }, + { id: "vests", label: "Vests" }, + { id: "backpacks", label: "Backpacks" }, + { id: "attachments", label: "Attachments" }, + { id: "weapons", label: "Weapons" }, + { id: "ammo", label: "Ammo" }, + { id: "misc", label: "Misc" }, + { id: "vehicles", label: "Vehicles" }, + ], + vehicleCards: [ + { id: "cars", label: "Cars" }, + { id: "armor", label: "Armor" }, + { id: "helis", label: "Helicopters" }, + { id: "planes", label: "Planes" }, + { id: "naval", label: "Naval" }, + { id: "other", label: "Other" }, + ], + weaponCards: [ + { id: "primary", label: "Primary" }, + { id: "secondary", label: "Secondary" }, + { id: "handgun", label: "Handgun" }, + ], + previewItems: { + uniforms: [], + headgear: [], + facewear: [], + vests: [], + backpacks: [], + attachments: [], + ammo: [], + misc: [], + primary: [], + secondary: [], + handgun: [], + cars: [], + armor: [], + helis: [], + planes: [], + naval: [], + other: [], + }, + }; + + StorefrontApp.data = { + catalog, + session: Object.assign({}, defaultSession), + storeConfig: Object.assign({}, defaultStoreConfig), + applyHydratePayload(payload) { + replaceObject( + this.session, + Object.assign({}, defaultSession, payload?.session || {}), + ); + replaceObject( + this.storeConfig, + Object.assign( + {}, + defaultStoreConfig, + payload?.storeConfig || {}, + ), + ); + }, + }; +})(); diff --git a/arma/client/addons/store/ui/src/media.js b/arma/client/addons/store/ui/src/media.js new file mode 100644 index 0000000..31f36fb --- /dev/null +++ b/arma/client/addons/store/ui/src/media.js @@ -0,0 +1,267 @@ +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const runtime = StorefrontApp.runtime; + const [getTextureVersion, setTextureVersion] = runtime.createSignal(0); + const MAX_CONCURRENT_TEXTURES = 6; + const RERENDER_DELAY_MS = 48; + const textureCache = Object.create(null); + const textureRequests = Object.create(null); + const queuedTexturePaths = []; + const queuedTextureLookup = Object.create(null); + const visibleTexturePaths = Object.create(null); + const observedTextureNodes = new WeakSet(); + let activeTextureRequests = 0; + let observer = null; + let observerRoot = null; + let rerenderTimer = 0; + + function normalizeTexturePath(path) { + let normalizedPath = String(path || "").trim(); + if (!normalizedPath) { + return ""; + } + + while ( + normalizedPath.startsWith("\\") || + normalizedPath.startsWith("/") + ) { + normalizedPath = normalizedPath.slice(1); + } + + if (!/\.[A-Za-z0-9]+$/.test(normalizedPath)) { + normalizedPath += ".paa"; + } + + return normalizedPath; + } + + function isBrowserTextureSource(path) { + const value = String(path || "") + .trim() + .toLowerCase(); + return ( + value.startsWith("data:image/") || + value.startsWith("blob:") || + value.startsWith("http://") || + value.startsWith("https://") + ); + } + + function finalizeTextureSource(path, source) { + textureCache[path] = source; + + scheduleRerender(); + } + + function scheduleRerender() { + if (rerenderTimer) { + return; + } + + rerenderTimer = window.setTimeout(() => { + rerenderTimer = 0; + setTextureVersion((currentVersion) => currentVersion + 1); + }, RERENDER_DELAY_MS); + } + + function pumpTextureQueue() { + if ( + typeof A3API === "undefined" || + typeof A3API.RequestTexture !== "function" + ) { + return; + } + + while ( + activeTextureRequests < MAX_CONCURRENT_TEXTURES && + queuedTexturePaths.length > 0 + ) { + const normalizedPath = queuedTexturePaths.shift(); + delete queuedTextureLookup[normalizedPath]; + + if ( + !normalizedPath || + textureCache[normalizedPath] !== undefined || + textureRequests[normalizedPath] + ) { + continue; + } + + activeTextureRequests += 1; + textureRequests[normalizedPath] = Promise.resolve( + A3API.RequestTexture(normalizedPath, 512), + ) + .then((resolvedPath) => { + const textureSource = String(resolvedPath || "").trim(); + + if (isBrowserTextureSource(textureSource)) { + finalizeTextureSource(normalizedPath, textureSource); + return; + } + + console.warn( + "[Store UI] Ignoring unsupported texture response.", + normalizedPath, + textureSource, + ); + finalizeTextureSource(normalizedPath, ""); + }) + .catch((error) => { + console.warn( + "[Store UI] Failed to resolve texture.", + normalizedPath, + error, + ); + finalizeTextureSource(normalizedPath, ""); + }) + .finally(() => { + activeTextureRequests = Math.max( + 0, + activeTextureRequests - 1, + ); + delete textureRequests[normalizedPath]; + pumpTextureQueue(); + }); + } + } + + function queueTextureRequest(path) { + if (!path || queuedTextureLookup[path] || textureRequests[path]) { + return; + } + + queuedTextureLookup[path] = true; + queuedTexturePaths.push(path); + pumpTextureQueue(); + } + + function markTextureVisible(path) { + const normalizedPath = normalizeTexturePath(path); + if (!normalizedPath || visibleTexturePaths[normalizedPath]) { + return; + } + + visibleTexturePaths[normalizedPath] = true; + if ( + !isBrowserTextureSource(textureCache[normalizedPath]) && + !textureRequests[normalizedPath] + ) { + queueTextureRequest(normalizedPath); + } + } + + function ensureObserver() { + const currentRoot = document.querySelector(".catalog-grid"); + if (typeof IntersectionObserver !== "function") { + return null; + } + + if (observer && observerRoot === currentRoot) { + return observer; + } + + if (observer) { + observer.disconnect(); + } + + observerRoot = currentRoot; + observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) { + return; + } + + const rawPath = entry.target.getAttribute( + "data-store-texture-path", + ); + markTextureVisible(rawPath); + observer.unobserve(entry.target); + }); + }, + { + root: currentRoot, + rootMargin: "240px 0px", + threshold: 0.01, + }, + ); + + return observer; + } + + function observeTextureTargets() { + const targets = document.querySelectorAll("[data-store-texture-path]"); + if (targets.length === 0) { + return; + } + + const activeObserver = ensureObserver(); + targets.forEach((target) => { + if (observedTextureNodes.has(target)) { + return; + } + + observedTextureNodes.add(target); + + const rawPath = target.getAttribute("data-store-texture-path"); + if (!activeObserver) { + markTextureVisible(rawPath); + return; + } + + activeObserver.observe(target); + }); + } + + function scheduleTextureObservation() { + window.requestAnimationFrame(() => { + observeTextureTargets(); + }); + } + + function getTextureState(path) { + getTextureVersion(); + const normalizedPath = normalizeTexturePath(path); + return { + path: normalizedPath, + isVisible: Boolean( + normalizedPath && visibleTexturePaths[normalizedPath], + ), + isLoaded: Boolean( + normalizedPath && + textureCache[normalizedPath] && + isBrowserTextureSource(textureCache[normalizedPath]), + ), + }; + } + + function getTextureSource(path) { + getTextureVersion(); + const normalizedPath = normalizeTexturePath(path); + if (!normalizedPath) { + return ""; + } + + if (isBrowserTextureSource(path)) { + textureCache[normalizedPath] = String(path).trim(); + return textureCache[normalizedPath]; + } + + if (textureCache[normalizedPath] !== undefined) { + return textureCache[normalizedPath]; + } + + if (visibleTexturePaths[normalizedPath]) { + queueTextureRequest(normalizedPath); + return ""; + } + + return ""; + } + + StorefrontApp.media = { + getTextureState, + getTextureSource, + scheduleTextureObservation, + }; +})(); diff --git a/arma/client/addons/store/ui/src/pages/StoreView.js b/arma/client/addons/store/ui/src/pages/StoreView.js new file mode 100644 index 0000000..c1ec182 --- /dev/null +++ b/arma/client/addons/store/ui/src/pages/StoreView.js @@ -0,0 +1,290 @@ +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const CATALOG_PAGE_SIZE = 6; + + function getSelectionKey(state) { + return ( + state.selectedWeaponSlot || + state.selectedVehicleSlot || + state.selectedCategory + ); + } + + function matchesQuery(query, values) { + if (!query) { + return true; + } + + const normalizedQuery = String(query).trim().toLowerCase(); + if (!normalizedQuery) { + return true; + } + + return values.some((value) => + String(value || "") + .toLowerCase() + .includes(normalizedQuery), + ); + } + + function parsePrice(value) { + const parsed = Number(String(value || "0").replace(/[^0-9.-]+/g, "")); + return Number.isFinite(parsed) ? parsed : 0; + } + + function formatCurrency(value) { + return `$${Number(value || 0).toLocaleString()}`; + } + + function formatTitle(value) { + const normalizedValue = String(value || "") + .trim() + .toLowerCase(); + if (["items", "misc"].includes(normalizedValue)) { + return "Misc"; + } + + return String(value || "") + .replace(/[-_]+/g, " ") + .split(/\s+/) + .filter(Boolean) + .map( + (part) => + part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(), + ) + .join(" "); + } + + function getStoreState(store) { + return { + view: store.getView(), + selectedCategory: store.getSelectedCategory(), + selectedWeaponSlot: store.getSelectedWeaponSlot(), + selectedVehicleSlot: store.getSelectedVehicleSlot(), + selectedPaymentSource: store.getSelectedPaymentSource(), + cartOpen: store.getCartOpen(), + searchQuery: store.getSearchQuery(), + cartItems: store.getCartItems(), + catalogItemsByKey: store.getCatalogItemsByKey(), + isCatalogLoading: store.getIsCatalogLoading(), + catalogRequestKey: store.getCatalogRequestKey(), + catalogPage: store.getCatalogPage(), + isCheckingOut: store.getIsCheckingOut(), + }; + } + + function getStoreHeader(state) { + if (state.view === "weapons") { + return { + eyebrow: "Weapons Division", + title: "Weapon Categories", + copy: "Select a weapon slot to open the next supply tier. Primary, secondary, and handgun are staged with the same state and bridge flow as the org portal.", + badge: "3 Slots", + }; + } + + if (state.view === "vehicles") { + return { + eyebrow: "Vehicle Motorpool", + title: "Vehicle Categories", + copy: "Select a vehicle class to open the next supply tier. Cars, armor, airframes, and naval options stay inside the same local store and bridge lifecycle.", + badge: "6 Classes", + }; + } + + if (state.view === "items") { + const label = getSelectionKey(state) || "catalog"; + const queryLabel = state.searchQuery + ? ` Filtered by "${state.searchQuery}".` + : ""; + const loadingLabel = state.isCatalogLoading + ? " Pulling live inventory from the game engine." + : ""; + + return { + eyebrow: "Catalog Preview", + title: formatTitle(label), + copy: `Live category inventory generated from the game engine for the selected department.${queryLabel}${loadingLabel}`, + badge: "Preview Items", + }; + } + + return { + eyebrow: "Supply Categories", + title: "Procurement Dashboard", + copy: "Choose a category to enter the exchange. Weapons and vehicles open a second tier, while the other departments display placeholder product inventory inside the new runtime/store architecture.", + badge: "8 Categories", + }; + } + + function getStoreBreadcrumbs(state) { + const items = [{ id: "categories", label: "Supply Exchange" }]; + + if (state.view === "weapons") { + items.push({ id: "weapons", label: "Weapons" }); + return items; + } + + if (state.view === "vehicles") { + items.push({ id: "vehicles", label: "Vehicles" }); + return items; + } + + if (state.view === "items") { + if (state.selectedWeaponSlot) { + items.push({ id: "weapons", label: "Weapons" }); + items.push({ + id: "weapon-slot", + label: formatTitle(state.selectedWeaponSlot), + }); + return items; + } + + if (state.selectedVehicleSlot) { + items.push({ id: "vehicles", label: "Vehicles" }); + items.push({ + id: "vehicle-slot", + label: formatTitle(state.selectedVehicleSlot), + }); + return items; + } + + if (state.selectedCategory) { + items.push({ + id: "category", + label: formatTitle(state.selectedCategory), + }); + } + } + + return items; + } + + function getVisibleCategoryCards(state, catalog) { + return catalog.categoryCards.filter((category) => + matchesQuery(state.searchQuery, [category.id, category.label]), + ); + } + + function getVisibleSubcategoryCards(state, catalog) { + const source = + state.view === "vehicles" + ? catalog.vehicleCards + : catalog.weaponCards; + + return source.filter((category) => + matchesQuery(state.searchQuery, [category.id, category.label]), + ); + } + + function getVisibleItems(state, catalog) { + const key = getSelectionKey(state); + const categoryKey = String(key || "") + .trim() + .toLowerCase(); + const itemsByKey = state.catalogItemsByKey || {}; + const items = Array.isArray(itemsByKey[categoryKey]) + ? itemsByKey[categoryKey] + : []; + + return items.filter((item) => + matchesQuery(state.searchQuery, [ + item.className, + item.code, + item.name, + item.description, + item.price, + item.type, + ]), + ); + } + + function getCatalogPagination(state, catalog) { + const totalItems = getVisibleItems(state, catalog).length; + const totalPages = Math.max( + 1, + Math.ceil(totalItems / CATALOG_PAGE_SIZE), + ); + const currentPage = Math.min( + totalPages, + Math.max(1, Number(state.catalogPage || 1)), + ); + + return { + pageSize: CATALOG_PAGE_SIZE, + totalItems, + totalPages, + currentPage, + startIndex: + totalItems === 0 + ? 0 + : (currentPage - 1) * CATALOG_PAGE_SIZE + 1, + endIndex: Math.min(currentPage * CATALOG_PAGE_SIZE, totalItems), + }; + } + + function getVisibleItemsPage(state, catalog) { + const items = getVisibleItems(state, catalog); + const pagination = getCatalogPagination(state, catalog); + const startOffset = (pagination.currentPage - 1) * pagination.pageSize; + return items.slice(startOffset, startOffset + pagination.pageSize); + } + + function summarizeCart(cartItems) { + const itemCount = cartItems.reduce( + (sum, item) => sum + Number(item.quantity || 0), + 0, + ); + const subtotal = cartItems.reduce( + (sum, item) => + sum + parsePrice(item.price) * Number(item.quantity || 0), + 0, + ); + + return { + lineCount: cartItems.length, + itemCount, + subtotal, + total: subtotal, + }; + } + + function getPaymentSources(storeConfig) { + const paymentSources = Array.isArray(storeConfig?.paymentSources) + ? storeConfig.paymentSources + : []; + + return paymentSources.map((source) => ({ + id: String(source?.id || "").trim(), + label: String(source?.label || source?.id || "").trim(), + balance: Number(source?.balance || 0), + enabled: source?.enabled !== false, + detail: String(source?.detail || "").trim(), + })); + } + + function getPaymentSourceById(storeConfig, paymentSourceId) { + const sourceId = String(paymentSourceId || "").trim(); + return getPaymentSources(storeConfig).find( + (source) => source.id === sourceId, + ); + } + + StorefrontApp.getters = { + formatTitle, + formatCurrency, + parsePrice, + getSelectionKey, + getStoreState, + getStoreHeader, + getStoreBreadcrumbs, + getVisibleCategoryCards, + getVisibleSubcategoryCards, + getVisibleItems, + getVisibleItemsPage, + getCatalogPagination, + summarizeCart, + getPaymentSources, + getPaymentSourceById, + }; +})(); diff --git a/arma/client/addons/store/ui/src/registry/events.js b/arma/client/addons/store/ui/src/registry/events.js new file mode 100644 index 0000000..14cabc9 --- /dev/null +++ b/arma/client/addons/store/ui/src/registry/events.js @@ -0,0 +1,362 @@ +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const store = StorefrontApp.store; + const getters = StorefrontApp.getters; + const { storeConfig, session } = StorefrontApp.data; + + let noticeTimer = null; + + function showNotice(type, text) { + store.setNotice({ type, text }); + + if (noticeTimer) { + clearTimeout(noticeTimer); + } + + noticeTimer = setTimeout(() => { + store.setNotice({ type: "", text: "" }); + noticeTimer = null; + }, 3200); + } + + function normalizeCheckoutItem(item) { + return { + classname: String(item?.code || "").trim(), + category: String(item?.category || "") + .trim() + .toLowerCase(), + entryKind: String(item?.entryKind || "item") + .trim() + .toLowerCase(), + quantity: Math.max(1, Number(item?.quantity || 1)), + }; + } + + function buildCheckoutPayload(cartItems, paymentMethod, totalPrice) { + const payload = { + items: [], + vehicles: [], + totalPrice, + paymentMethod, + }; + + cartItems.forEach((item) => { + const normalizedItem = normalizeCheckoutItem(item); + + if (normalizedItem.entryKind === "vehicle") { + for ( + let index = 0; + index < normalizedItem.quantity; + index += 1 + ) { + payload.vehicles.push({ + classname: normalizedItem.classname, + category: normalizedItem.category, + }); + } + return; + } + + payload.items.push({ + classname: normalizedItem.classname, + category: normalizedItem.category, + quantity: normalizedItem.quantity, + }); + }); + + return payload; + } + + function applySearchQuery(value) { + store.setSearchQuery(String(value || "").trim()); + store.resetCatalogPage(); + } + + function clearSearch() { + store.setSearchQuery(""); + store.resetCatalogPage(); + } + + function toggleCart() { + store.setCartOpen((open) => !open); + } + + function closeCart() { + store.setCartOpen(false); + } + + function closeStore() { + const bridge = StorefrontApp.bridge; + if (bridge && typeof bridge.requestClose === "function") { + const sent = bridge.requestClose(); + if (sent) { + return true; + } + } + + showNotice("error", "Store bridge is unavailable."); + return false; + } + + function navigateToBreadcrumb(target) { + return store.navigateToBreadcrumb(target); + } + + function scrollCatalogToTop() { + const catalogGrid = document.querySelector( + '[data-preserve-scroll-id="catalog-grid"]', + ); + if (catalogGrid) { + catalogGrid.scrollTop = 0; + } + } + + function selectCategory(category) { + store.selectCategory(category); + scrollCatalogToTop(); + + if (!["weapons", "vehicles"].includes(String(category || ""))) { + requestCategoryItems(category); + } + } + + function selectSubcategory(subcategory, slotType) { + store.selectSubcategory(subcategory, slotType); + scrollCatalogToTop(); + requestCategoryItems(subcategory); + } + + function goToCatalogPage(page) { + store.setCatalogPageNumber(page); + scrollCatalogToTop(); + } + + function goToNextCatalogPage(totalPages) { + const currentPage = Number(store.getCatalogPage() || 1); + const lastPage = Math.max(1, Number(totalPages || 1)); + if (currentPage >= lastPage) { + return false; + } + + goToCatalogPage(currentPage + 1); + return true; + } + + function goToPreviousCatalogPage() { + const currentPage = Number(store.getCatalogPage() || 1); + if (currentPage <= 1) { + return false; + } + + goToCatalogPage(currentPage - 1); + return true; + } + + function requestCategoryItems(category) { + const categoryKey = String(category || "") + .trim() + .toLowerCase(); + if (!categoryKey) { + return false; + } + + const cachedItems = store.getCatalogItemsByKey(); + if (Array.isArray(cachedItems[categoryKey])) { + store.finishCategoryRequest(""); + return true; + } + + store.startCategoryRequest(categoryKey); + + const bridge = StorefrontApp.bridge; + if (!bridge || typeof bridge.requestCategory !== "function") { + store.finishCategoryRequest(categoryKey); + showNotice("error", "Store bridge is unavailable."); + return false; + } + + const sent = bridge.requestCategory({ category: categoryKey }); + if (!sent) { + store.finishCategoryRequest(categoryKey); + showNotice("error", "Category request bridge is unavailable."); + return false; + } + + return true; + } + + function addToCart(item) { + store.setCartItems((currentItems) => { + const existingIndex = currentItems.findIndex( + (entry) => entry.code === item.code, + ); + if (existingIndex === -1) { + return [ + ...currentItems, + { + code: item.code, + name: item.name, + price: item.price, + category: item.category, + entryKind: item.entryKind, + quantity: 1, + }, + ]; + } + + const nextItems = [...currentItems]; + nextItems[existingIndex] = Object.assign( + {}, + nextItems[existingIndex], + { + category: item.category, + entryKind: item.entryKind, + quantity: nextItems[existingIndex].quantity + 1, + }, + ); + return nextItems; + }); + + showNotice("success", `${item.name} added to the acquisition queue.`); + } + + function incrementCartItem(code) { + store.setCartItems((currentItems) => + currentItems.map((item) => + item.code === code + ? Object.assign({}, item, { quantity: item.quantity + 1 }) + : item, + ), + ); + } + + function decrementCartItem(code) { + store.setCartItems((currentItems) => + currentItems + .map((item) => + item.code === code + ? Object.assign({}, item, { + quantity: Math.max(0, item.quantity - 1), + }) + : item, + ) + .filter((item) => item.quantity > 0), + ); + } + + function removeCartItem(code) { + store.setCartItems((currentItems) => + currentItems.filter((item) => item.code !== code), + ); + } + + function selectPaymentSource(paymentSourceId) { + const sourceId = String(paymentSourceId || "").trim(); + const paymentSources = getters.getPaymentSources(storeConfig); + const selectedSource = paymentSources.find( + (source) => source.id === sourceId, + ); + + if (!selectedSource) { + showNotice("error", "Selected payment source is unavailable."); + return false; + } + + if (selectedSource.enabled === false) { + showNotice( + "error", + selectedSource.detail || + "Selected payment source is not available.", + ); + return false; + } + + store.setSelectedPaymentSource(sourceId); + return true; + } + + function requestCheckout() { + const cartItems = store.getCartItems(); + if (cartItems.length === 0) { + showNotice("error", "Add at least one item before checkout."); + return false; + } + + const summary = getters.summarizeCart(cartItems); + const selectedPaymentSource = getters.getPaymentSourceById( + storeConfig, + store.getSelectedPaymentSource(), + ); + + if (!selectedPaymentSource) { + showNotice("error", "Select a payment source before checkout."); + return false; + } + + if (selectedPaymentSource.enabled === false) { + showNotice( + "error", + selectedPaymentSource.detail || + "Selected payment source is unavailable.", + ); + return false; + } + + if (summary.total > Number(selectedPaymentSource.balance || 0)) { + showNotice( + "error", + `${selectedPaymentSource.label} cannot cover this checkout total.`, + ); + return false; + } + + const bridge = StorefrontApp.bridge; + if (!bridge || typeof bridge.requestCheckout !== "function") { + showNotice("error", "Checkout bridge is unavailable."); + return false; + } + + store.setIsCheckingOut(true); + + const checkoutPayload = buildCheckoutPayload( + cartItems, + selectedPaymentSource.id, + summary.total, + ); + + const sent = bridge.requestCheckout({ + checkoutJson: JSON.stringify(checkoutPayload), + }); + + if (!sent) { + store.setIsCheckingOut(false); + showNotice("error", "Checkout bridge is unavailable."); + return false; + } + + return true; + } + + StorefrontApp.actions = { + showNotice, + applySearchQuery, + clearSearch, + toggleCart, + closeCart, + closeStore, + navigateToBreadcrumb, + selectCategory, + selectSubcategory, + goToCatalogPage, + goToNextCatalogPage, + goToPreviousCatalogPage, + addToCart, + incrementCartItem, + decrementCartItem, + removeCartItem, + selectPaymentSource, + requestCheckout, + formatTitle: getters.formatTitle, + formatCurrency: getters.formatCurrency, + }; +})(); diff --git a/arma/client/addons/store/ui/src/registry/store.js b/arma/client/addons/store/ui/src/registry/store.js new file mode 100644 index 0000000..e8b608e --- /dev/null +++ b/arma/client/addons/store/ui/src/registry/store.js @@ -0,0 +1,288 @@ +(function () { + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + const { createSignal } = StorefrontApp.runtime; + const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); + + SharedLogic.createStorefrontStore = function createStorefrontStore({ + createSignal, + }) { + function normalizeCatalogItem(item) { + return { + className: String(item?.className || item?.code || ""), + code: String(item?.code || item?.className || ""), + name: String(item?.name || item?.displayName || ""), + description: String(item?.description || ""), + price: String(item?.price || ""), + image: String(item?.image || ""), + type: String(item?.type || ""), + category: String(item?.category || ""), + entryKind: String(item?.entryKind || "item"), + quantity: Math.max(0, Number(item?.quantity || 0)), + }; + } + + function normalizeCartItem(item) { + return { + code: String(item?.code || ""), + name: String(item?.name || ""), + price: String(item?.price || "$0"), + category: String(item?.category || ""), + entryKind: String(item?.entryKind || "item"), + quantity: Math.max(1, Number(item?.quantity || 1)), + }; + } + + class StorefrontStore { + constructor() { + [this.getView, this.setView] = createSignal("categories"); + [this.getSelectedCategory, this.setSelectedCategory] = + createSignal(""); + [this.getSelectedWeaponSlot, this.setSelectedWeaponSlot] = + createSignal(""); + [this.getSelectedVehicleSlot, this.setSelectedVehicleSlot] = + createSignal(""); + [this.getCartOpen, this.setCartOpen] = createSignal(false); + [this.getSearchQuery, this.setSearchQuery] = createSignal(""); + [this.getCartItems, this.setCartItems] = createSignal([]); + [this.getCatalogItemsByKey, this.setCatalogItemsByKey] = + createSignal({}); + [this.getIsCatalogLoading, this.setIsCatalogLoading] = + createSignal(false); + [this.getCatalogRequestKey, this.setCatalogRequestKey] = + createSignal(""); + [this.getCatalogPage, this.setCatalogPage] = createSignal(1); + [this.getNotice, this.setNotice] = createSignal({ + type: "", + text: "", + }); + [this.getIsCheckingOut, this.setIsCheckingOut] = + createSignal(false); + [this.getSelectedPaymentSource, this.setSelectedPaymentSource] = + createSignal("cash"); + } + + resetToCategories() { + this.setView("categories"); + this.setSelectedCategory(""); + this.setSelectedWeaponSlot(""); + this.setSelectedVehicleSlot(""); + this.setIsCatalogLoading(false); + this.setCatalogRequestKey(""); + this.setCatalogPage(1); + } + + openWeaponsRoot() { + this.setView("weapons"); + this.setSelectedCategory("weapons"); + this.setSelectedWeaponSlot(""); + this.setSelectedVehicleSlot(""); + this.setIsCatalogLoading(false); + this.setCatalogRequestKey(""); + this.setCatalogPage(1); + } + + openVehiclesRoot() { + this.setView("vehicles"); + this.setSelectedCategory("vehicles"); + this.setSelectedVehicleSlot(""); + this.setSelectedWeaponSlot(""); + this.setIsCatalogLoading(false); + this.setCatalogRequestKey(""); + this.setCatalogPage(1); + } + + resetCatalogPage() { + this.setCatalogPage(1); + } + + setCatalogPageNumber(page) { + const nextPage = Math.max(1, Number(page || 1)); + this.setCatalogPage(nextPage); + } + + selectCategory(category) { + this.setSelectedCategory(category); + this.setSelectedWeaponSlot(""); + this.setSelectedVehicleSlot(""); + this.setCatalogPage(1); + + if (category === "weapons") { + this.openWeaponsRoot(); + return; + } + + if (category === "vehicles") { + this.openVehiclesRoot(); + return; + } + + this.setView("items"); + } + + selectSubcategory(subcategory, slotType) { + if (slotType === "vehicle") { + this.setSelectedVehicleSlot(subcategory); + this.setSelectedWeaponSlot(""); + } else { + this.setSelectedWeaponSlot(subcategory); + this.setSelectedVehicleSlot(""); + } + + this.setCatalogPage(1); + this.setView("items"); + } + + startCategoryRequest(category) { + const categoryKey = String(category || "") + .trim() + .toLowerCase(); + if (!categoryKey) { + return false; + } + + this.setCatalogRequestKey(categoryKey); + this.setIsCatalogLoading(true); + return true; + } + + finishCategoryRequest(category) { + const categoryKey = String(category || "") + .trim() + .toLowerCase(); + const activeKey = String(this.getCatalogRequestKey() || "") + .trim() + .toLowerCase(); + + if (!categoryKey || !activeKey || activeKey === categoryKey) { + this.setCatalogRequestKey(""); + this.setIsCatalogLoading(false); + } + } + + hydrateCategoryItems(payload) { + const categoryKey = String(payload?.category || "") + .trim() + .toLowerCase(); + const items = Array.isArray(payload?.items) + ? payload.items + : []; + + if (!categoryKey) { + this.setCatalogRequestKey(""); + this.setIsCatalogLoading(false); + return; + } + + this.setCatalogItemsByKey((currentItemsByKey) => + Object.assign({}, currentItemsByKey, { + [categoryKey]: items.map(normalizeCatalogItem), + }), + ); + + this.finishCategoryRequest(categoryKey); + } + + ensureSelectedPaymentSource(storeConfig) { + const paymentSources = Array.isArray( + storeConfig?.paymentSources, + ) + ? storeConfig.paymentSources + : []; + const currentSource = String( + this.getSelectedPaymentSource() || "", + ).trim(); + const defaultSource = String( + storeConfig?.defaultPaymentSource || "", + ).trim(); + const sourceIds = paymentSources.map((source) => + String(source?.id || "").trim(), + ); + const enabledSource = paymentSources.find( + (source) => source && source.enabled !== false, + ); + const defaultAvailable = + defaultSource && sourceIds.includes(defaultSource) + ? paymentSources.find( + (source) => + String(source?.id || "").trim() === + defaultSource, + ) + : null; + + if ( + currentSource && + sourceIds.includes(currentSource) && + paymentSources.some( + (source) => + String(source?.id || "").trim() === currentSource && + source?.enabled !== false, + ) + ) { + return; + } + + if (defaultAvailable && defaultAvailable.enabled !== false) { + this.setSelectedPaymentSource(defaultSource); + return; + } + + if (enabledSource) { + this.setSelectedPaymentSource( + String(enabledSource.id || "cash"), + ); + return; + } + + this.setSelectedPaymentSource(defaultSource || "cash"); + } + + navigateToBreadcrumb(target) { + switch (target) { + case "categories": + this.resetToCategories(); + return true; + case "weapons": + this.openWeaponsRoot(); + return true; + case "vehicles": + this.openVehiclesRoot(); + return true; + default: + return false; + } + } + + hydrateFromPayload(payload) { + const cartItems = Array.isArray(payload?.cartItems) + ? payload.cartItems + : []; + + this.setCartItems(cartItems.map(normalizeCartItem)); + this.setCartOpen(false); + this.setIsCheckingOut(false); + this.setCatalogItemsByKey({}); + this.setCatalogRequestKey(""); + this.setIsCatalogLoading(false); + this.setCatalogPage(1); + this.ensureSelectedPaymentSource(payload?.storeConfig || {}); + } + + hydrateStoreConfig(payload) { + const cartItems = Array.isArray(payload?.cartItems) + ? payload.cartItems + : []; + + this.setCartItems(cartItems.map(normalizeCartItem)); + this.setCartOpen(false); + this.setIsCheckingOut(false); + this.ensureSelectedPaymentSource(payload?.storeConfig || {}); + } + } + + return new StorefrontStore(); + }; + + StorefrontApp.store = SharedLogic.createStorefrontStore({ + createSignal, + }); +})(); diff --git a/arma/client/addons/store/ui/src/runtime.js b/arma/client/addons/store/ui/src/runtime.js new file mode 100644 index 0000000..2462fdd --- /dev/null +++ b/arma/client/addons/store/ui/src/runtime.js @@ -0,0 +1,6 @@ +(function () { + const runtime = window.ForgeWebUI; + const StorefrontApp = (window.StorefrontApp = window.StorefrontApp || {}); + StorefrontApp.runtime = runtime; + window.AppRuntime = runtime; +})(); diff --git a/arma/client/addons/store/ui/src/styles.css b/arma/client/addons/store/ui/src/styles.css new file mode 100644 index 0000000..9bda616 --- /dev/null +++ b/arma/client/addons/store/ui/src/styles.css @@ -0,0 +1,89 @@ +:root { + --store-shell-bg: #e4e3df; + --store-surface: #f5f3ef; + --store-surface-alt: #ece8e2; + --store-surface-strong: #ffffff; + --store-border: rgba(74, 91, 110, 0.2); + --store-border-strong: rgba(20, 46, 79, 0.2); + --store-text-main: #1f2d3d; + --store-text-muted: #6a7787; + --store-text-subtle: #8792a0; + --store-accent: #12365d; + --store-accent-soft: #dbe7f3; + --store-accent-line: rgba(18, 54, 93, 0.12); + --store-success: #2f7d5b; + --store-danger: #8a3d3d; +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +body { + font-family: "Segoe UI", "Trebuchet MS", sans-serif; + color: var(--store-text-main); + background: var(--store-shell-bg); +} + +button, +input, +select { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.7; +} + +:focus-visible { + outline: 2px solid rgb(18 54 93 / 0.35); + outline-offset: 2px; +} + +#app { + width: 100%; + height: 100%; +} + +.store-btn { + min-height: 2.75rem; + padding: 0.72rem 1rem; + border-radius: 0.8rem; + border: 1px solid var(--store-border-strong); + font-size: 0.82rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.store-btn.store-btn-primary { + background: rgb(255 255 255 / 0.68); + color: var(--store-accent); +} + +.store-btn.store-btn-primary:hover { + background: rgb(219 231 243 / 0.88); +} + +.store-btn.store-btn-secondary { + background: rgb(255 255 255 / 0.42); + color: var(--store-text-muted); +} + +.store-btn.store-btn-secondary:hover { + background: rgb(255 255 255 / 0.6); + color: var(--store-text-main); +} diff --git a/arma/client/addons/store/ui/ui.config.mjs b/arma/client/addons/store/ui/ui.config.mjs new file mode 100644 index 0000000..b99ac7b --- /dev/null +++ b/arma/client/addons/store/ui/ui.config.mjs @@ -0,0 +1,38 @@ +export default { + addonName: "store", + title: "FORGE Supply Exchange", + logLabel: "Store UI", + outputDir: "_site", + jsBundles: [ + { + name: "Store UI app", + output: "store-ui.js", + sources: [ + "src/runtime.js", + "src/media.js", + "src/data.js", + "src/registry/store.js", + "src/pages/StoreView.js", + "src/bridge.js", + "src/registry/events.js", + "src/components/AppShell.js", + "src/components/cards.js", + "src/components/cart.js", + "src/components/navbar.js", + "src/bootstrap.js", + ], + }, + ], + cssBundles: [ + { + name: "Store UI styles", + output: "store-ui.css", + sources: ["src/styles.css"], + }, + ], + site: { + styles: ["store-ui.css"], + commonScripts: ["forge-webui.js"], + scripts: ["store-ui.js"], + }, +}; diff --git a/arma/client/client.code-workspace b/arma/client/client.code-workspace index e24ee21..661fbc1 100644 --- a/arma/client/client.code-workspace +++ b/arma/client/client.code-workspace @@ -1,8 +1,8 @@ { "folders": [ { - "path": "." - } + "path": ".", + }, ], "settings": { "editor.insertSpaces": true, @@ -16,7 +16,7 @@ "*.hpp": "arma-config", "*.inc": "arma-config", "*.cfg": "arma-config", - "*.rvmat": "arma-config" - } - } + "*.rvmat": "arma-config", + }, + }, } diff --git a/arma/client/docs/README.md b/arma/client/docs/README.md index 8320d12..c41b1e5 100644 --- a/arma/client/docs/README.md +++ b/arma/client/docs/README.md @@ -23,14 +23,16 @@

# Initial Project Setup! + Delete this section after the project has been initially set up: + 1. Find and replace all instances of `forge-client` with the mod's name. -2. Find and replace all instances of `MOD_REPO` with the mod's name *and no spaces*. - - This should be the name of the repository on GitHub. +2. Find and replace all instances of `MOD_REPO` with the mod's name _and no spaces_. + - This should be the name of the repository on GitHub. 3. Find and replace all instances of `forge_client` with the mod's prefix. - - This should be all lowercase. + - This should be all lowercase. 4. Find and replace all instances of `MOD_ACRONYM` with the mod's acronym. - - This should be all uppercase. + - This should be all uppercase. 5. After the initial Steam upload, find and replace all instances of `MOD_ID` with the mod's Steam Workshop id. For third parties, make sure to also replace `IDSolutions` with your Github username / organization name, and to replace `DartRuffian` with your username. @@ -40,10 +42,13 @@ For third parties, make sure to also replace `IDSolutions` with your Github user The project is entirely **open-source** and any contributions are welcome. ## Core Features + - Feature ## Contributing + For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md). ## License + forge-client is licensed under [APL-ND](./LICENSE.md). diff --git a/arma/client/extra/example_addon/README.md b/arma/client/extra/example_addon/README.md index 87592d8..40e0345 100644 --- a/arma/client/extra/example_addon/README.md +++ b/arma/client/extra/example_addon/README.md @@ -1,4 +1,3 @@ -forge_client_addonName -=================== +# forge_client_addonName Description for this addon diff --git a/arma/client/extra/example_addon/functions/fnc_empty.sqf b/arma/client/extra/example_addon/functions/fnc_empty.sqf index 07b0282..73c0a37 100644 --- a/arma/client/extra/example_addon/functions/fnc_empty.sqf +++ b/arma/client/extra/example_addon/functions/fnc_empty.sqf @@ -10,7 +10,7 @@ * None * * Example: - * [] call forge_client_addonName_fnc_empty; + * call forge_client_addonName_fnc_empty; * * Public: No */ diff --git a/arma/client/extra/notify.wav b/arma/client/extra/notify.wav new file mode 100644 index 0000000..88a18e1 Binary files /dev/null and b/arma/client/extra/notify.wav differ diff --git a/arma/server/.github/CONTRIBUTING.md b/arma/server/.github/CONTRIBUTING.md index b7b4679..a378b46 100644 --- a/arma/server/.github/CONTRIBUTING.md +++ b/arma/server/.github/CONTRIBUTING.md @@ -1,12 +1,17 @@ # Contributing Setup & Guidelines ## Setting up the Development Environment + ### 1. Clone the repository from GitHub + ### 2. Install HEMTT + The latest version of HEMTT can be installed by running: + ```cmd winget install hemtt ``` ## Coding Guidelines + This mod follows the same coding guidelines as the ACE3 mod, which can be found [here](https://ace3.acemod.org/wiki/development/coding-guidelines). diff --git a/arma/server/.github/ISSUE_TEMPLATE/bug-report.md b/arma/server/.github/ISSUE_TEMPLATE/bug-report.md index d4c384f..2e818b5 100644 --- a/arma/server/.github/ISSUE_TEMPLATE/bug-report.md +++ b/arma/server/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,25 +1,31 @@ --- name: Bug report about: Create a bug report to help us improve -title: '' +title: "" labels: kind/bug --- ## Describe the bug + A clear and concise description of what the bug is. ## To reproduce + Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error ## Expected behavior + A clear and concise description of what you expected to happen. ## Attachments + If applicable, add screenshots or RPT logs to help explain your problem. ## Additional context + Add any other context about the problem here. diff --git a/arma/server/.github/ISSUE_TEMPLATE/feature-request.md b/arma/server/.github/ISSUE_TEMPLATE/feature-request.md index 709ee6c..7bd655d 100644 --- a/arma/server/.github/ISSUE_TEMPLATE/feature-request.md +++ b/arma/server/.github/ISSUE_TEMPLATE/feature-request.md @@ -1,15 +1,18 @@ --- name: Feature Request about: Suggest a feature to be added -title: '' +title: "" labels: kind/feature-request --- ## Describe the feature that you would like + A clear and concise description of the feature you'd want. ## Possible alternatives + Possible alternatives to your suggestion. ## Additional context + Add any other context about the feature here. diff --git a/arma/server/.github/PULL_REQUEST_TEMPLATE.md b/arma/server/.github/PULL_REQUEST_TEMPLATE.md index 6f72c35..1721684 100644 --- a/arma/server/.github/PULL_REQUEST_TEMPLATE.md +++ b/arma/server/.github/PULL_REQUEST_TEMPLATE.md @@ -1,12 +1,16 @@ **When merged this pull request will:** + - Describe what this pull request will do - Each change in a separate line ### Important + - [ ] If the contribution affects [the documentation](../docs), please include your changes in this pull request. - [ ] [Development Guidelines](https://github.com/IDSolutions/MOD_REPO/blob/main/.github/CONTRIBUTING.md) are read, understood and applied. - [ ] Title of this PR uses our standard template `Component - Add|Fix|Improve|Change|Make|Remove {changes}`. + ### Known Issues + - [ ] Issue diff --git a/arma/server/.github/workflows/check.yml b/arma/server/.github/workflows/check.yml index 9d2f654..abb328f 100644 --- a/arma/server/.github/workflows/check.yml +++ b/arma/server/.github/workflows/check.yml @@ -12,17 +12,17 @@ jobs: validate: runs-on: ubuntu-latest steps: - - name: Checkout the source code - uses: actions/checkout@v4 + - name: Checkout the source code + uses: actions/checkout@v4 - - name: Validate Config - run: python tools/config_style_checker.py - - name: Check for BOM - uses: arma-actions/bom-check@master - with: - path: "addons" + - name: Validate Config + run: python tools/config_style_checker.py + - name: Check for BOM + uses: arma-actions/bom-check@master + with: + path: "addons" - - name: Setup HEMTT - uses: arma-actions/hemtt@v1 - - name: Run HEMTT check - run: hemtt check --pedantic + - name: Setup HEMTT + uses: arma-actions/hemtt@v1 + - name: Run HEMTT check + run: hemtt check --pedantic diff --git a/arma/server/.gitignore b/arma/server/.gitignore index b786b16..41f642f 100644 --- a/arma/server/.gitignore +++ b/arma/server/.gitignore @@ -3,6 +3,7 @@ hemtt.exe .hemtt/missions/~* .hemttout/ releases/ +.hemttprivatekey # Textures Exports/ diff --git a/arma/server/LICENSE.md b/arma/server/LICENSE.md index 659cbdc..0cb1a9b 100644 --- a/arma/server/LICENSE.md +++ b/arma/server/LICENSE.md @@ -6,10 +6,10 @@ PLEASE, NOTE THAT THIS SUMMARY HAS NO LEGAL EFFECT AND IS ONLY OF AN INFORMATORY With this licence you are free to adapt (i.e. modify, rework or update) and share (i.e. copy, distribute or transmit) the material under the following conditions: -* **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material). -* **Noncommercial** - You may not use this material for any commercial purposes. -* **Arma Only** - You may not convert or adapt this material to be used in other games than Arma. -* **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license. +- **Attribution** - You must attribute the material in the manner specified by the author or licensor (but not in any way that suggests that they endorse you or your use of the material). +- **Noncommercial** - You may not use this material for any commercial purposes. +- **Arma Only** - You may not convert or adapt this material to be used in other games than Arma. +- **Share Alike** - If you adapt, or build upon this material, you may distribute the resulting material only under the same license. --- @@ -97,7 +97,7 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your 2. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 2. upon express reinstatement by the Licensor. - For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. + For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. 3. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. 4. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. @@ -116,4 +116,4 @@ For the avoidance of doubt, this Section 4 supplements and does not replace Your ### Bohemia Interactive Notices 1. Bohemia Interactive a.s. is not a party to this License, and makes no warranty whatsoever in connection with the Licensed Material. Bohemia Interactive a.s. will not be liable to You or any party on any legal theory for any damages whatsoever, including without limitation any general, special, incidental or consequential damages arising in connection to this license. Notwithstanding the foregoing two (2) sentences, Bohemia Interactive a.s. may elect to apply the Public License to material it publishes and in those instances it becomes the "Licensor". -2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s. \ No newline at end of file +2. Except for the limited purpose of indicating to the public that the Licensed Material is shared under this Public License, Bohemia Interactive a.s. does not authorize the use by either party of the trademarks "Arma", "Bohemia Interactive" or any related trademark or logo of Arma or Bohemia Interactive without the prior written consent of Bohemia Interactive a.s. diff --git a/arma/server/README.md b/arma/server/README.md index 970fcd3..99c542a 100644 --- a/arma/server/README.md +++ b/arma/server/README.md @@ -18,10 +18,13 @@ The project is entirely **open-source** and any contributions are welcome. ## Core Features + - Feature ## Contributing + For new contributers, see the [Contributing Setup & Guidelines](./.github/CONTRIBUTING.md). ## License + Forge Server is licensed under [APL-SA](./LICENSE.md). diff --git a/arma/server/addons/actor/README.md b/arma/server/addons/actor/README.md index 66b48ec..004359b 100644 --- a/arma/server/addons/actor/README.md +++ b/arma/server/addons/actor/README.md @@ -1,4 +1,3 @@ -forge_server_actor -=================== +# forge_server_actor Description for this addon diff --git a/arma/server/addons/actor/XEH_preInit.sqf b/arma/server/addons/actor/XEH_preInit.sqf index d776427..46cf2f8 100644 --- a/arma/server/addons/actor/XEH_preInit.sqf +++ b/arma/server/addons/actor/XEH_preInit.sqf @@ -7,34 +7,32 @@ PREP_RECOMPILE_END; // private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)]; [QGVAR(requestInitActor), { - params [["_uid", "", [""]], ["_actor", createHashMap, [createHashMap]]]; + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid UID!" }; - if (_actor isEqualTo createHashMap) exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid Actor data!" }; - - GVAR(ActorStore) call ["init", [_uid, _actor]]; + GVAR(ActorStore) call ["init", [_uid]]; }] call CFUNC(addEventHandler); [QGVAR(requestGetActor), { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + params [["_uid", "", [""]], ["_field", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid UID!" }; - private _session = GVAR(PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid Session!" }; + private _finalData = GVAR(ActorStore) call ["get", [GVAR(Registry), _uid, _field]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(ActorStore) call ["get", [_uid, _sync]]; + [CRPC(actor,responseSyncActor), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestSetActor), { - params [["_uid", "", [""]], ["_key", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; + params [["_uid", "", [""]], ["_field", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; - if (_uid isEqualTo "" || _key isEqualTo "") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid UID or Key!" }; + if (_uid isEqualTo "" || _field isEqualTo "") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid UID or Key!" }; - private _session = GVAR(PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid Session!" }; + private _hashMap = GVAR(ActorStore) call ["set", [GVAR(Registry), "actor:update", _uid, _field, _value, _sync]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(ActorStore) call ["set", [_uid, _key, _value, _sync]]; + [CRPC(actor,responseSyncActor), [_hashMap], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestMSetActor), { @@ -43,27 +41,27 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid UID!" }; if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid field pairs!" }; - private _session = GVAR(PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid Session!" }; + private _hashMap = GVAR(ActorStore) call ["mset", [GVAR(Registry), "actor:update", _uid, _fieldValuePairs, _sync]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(ActorStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; + [CRPC(actor,responseSyncActor), [_hashMap], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestSaveActor), { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid UID!" }; - private _session = GVAR(PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid Session!" }; + GVAR(ActorStore) call ["snapshot", [_uid]]; + private _finalData = GVAR(ActorStore) call ["save", [GVAR(Registry), "actor:update", _uid]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(ActorStore) call ["save", [_uid, _sync]]; + [CRPC(actor,responseSyncActor), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestRemoveActor), { params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Actor] Empty/Invalid UID!" }; - - GVAR(ActorStore) call ["remove", [_uid]]; + GVAR(ActorStore) call ["remove", [GVAR(Registry), _uid]]; }] call CFUNC(addEventHandler); diff --git a/arma/server/addons/actor/XEH_preStart.sqf b/arma/server/addons/actor/XEH_preStart.sqf index 0228885..a51262a 100644 --- a/arma/server/addons/actor/XEH_preStart.sqf +++ b/arma/server/addons/actor/XEH_preStart.sqf @@ -1,3 +1,2 @@ #include "script_component.hpp" - #include "XEH_PREP.hpp" diff --git a/arma/server/addons/actor/functions/fnc_initActorStore.sqf b/arma/server/addons/actor/functions/fnc_initActorStore.sqf index 32ac128..a82e17d 100644 --- a/arma/server/addons/actor/functions/fnc_initActorStore.sqf +++ b/arma/server/addons/actor/functions/fnc_initActorStore.sqf @@ -1,85 +1,198 @@ #include "..\script_component.hpp" /* + * File: fnc_initActorStore.sqf * Author: IDSolutions - * Initializes the actor store. + * Date: 2025-12-17 + * Last Update: 2026-02-13 + * Public: Yes + * + * Description: + * Initializes the actor store for managing player actor data. + * Provides methods for creating, fetching, migrating, and validating actor data. * * Arguments: * None * * Return Value: - * None + * Actor store object [HASHMAP OBJECT] * - * Examples: - * [] call forge_server_actor_fnc_initActorStore - * - * Public: Yes + * Example: + * call forge_server_actor_fnc_initActorStore */ #pragma hemtt ignore_variables ["_self"] -GVAR(ActorStore) = createHashMapObject [[ - ["#base", EGVAR(common,BaseStore)], - ["#type", "IActorStore"], - ["#create", { - GVAR(ActorRegistry) = createHashMap; - GVAR(PlayerSessions) = createHashMap; +GVAR(ActorModel) = compileFinal createHashMapObject [[ + ["#type", "ActorModel"], + ["defaults", compileFinal { + private _actor = createHashMap; - _self set ["_registry", GVAR(ActorRegistry)]; - _self set ["_extCallPrefix", "actor"]; - _self set ["_readMethod", "get"]; - _self set ["_storeName", "Actor"]; - _self set ["_syncEventName", CRPC(actor,responseSyncActor)]; + _actor set ["uid", ""]; + _actor set ["name", ""]; + _actor set ["loadout", [[],[],[],["U_BG_Guerrilla_6_1",[["FirstAidKit", 2]]],[],[],"H_Cap_blk_ION","",[],["ItemMap","ItemGPS","ItemRadio","ItemCompass","ItemWatch",""]]]; + _actor set ["position", [0,0,0]]; + _actor set ["direction", 0]; + _actor set ["stance", "STAND"]; + _actor set ["rank", "PRIVATE"]; + _actor set ["state", "HEALTHY"]; + _actor set ["phone_number", ""]; + _actor set ["email", ""]; + _actor set ["organization", ""]; + _actor set ["holster", true]; - ["INFO", "Actor Store Initialized!", nil, nil] call EFUNC(common,log); + _actor }], - ["generateSessionToken", { - params [["_uid", "", [""]]]; + ["fromPlayer", compileFinal { + params [["_player", objNull, [objNull]]]; - private _token = format ["%1_%2_%3", _uid, floor(random 999999), time]; - private _sessionToken = _token call EFUNC(common,generateHash); + if (_player isEqualTo objNull) exitWith { _self call ["defaults", []] }; - private _regEntry = createHashMapFromArray [["sessionToken", _sessionToken]]; - GVAR(PlayerSessions) set [_uid, _regEntry]; + private _actor = _self call ["defaults", []]; - _sessionToken + _actor set ["uid", getPlayerUID _player]; + _actor set ["name", name _player]; + _actor set ["position", getPosASL _player]; + _actor set ["direction", getDir _player]; + _actor set ["stance", stance _player]; + _actor set ["rank", rank _player]; + _actor set ["state", lifeState _player]; + + _actor }], - ["init", { - params [["_uid", "", [""]], ["_defaultActor", createHashMap, [createHashMap]]]; + ["migrate", compileFinal { + params [["_actor", createHashMap, [createHashMap]]]; - _self call ["generateSessionToken", [_uid]]; - private _finalActor = createHashMap; + private _defaults = _self call ["defaults", []]; - ["actor:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - private _exists = _result == "true"; + { + if !(_x in _actor) then { _actor set [_x, _y]; }; + } forEach _defaults; - if !(_exists) then { - _finalActor = _defaultActor; - _finalActor set ["uid", _uid]; + _actor + }], + ["validate", compileFinal { + params [["_actor", createHashMap, [createHashMap]]]; - private _json = _self call ["toJSON", [_finalActor]]; - ["actor:create", [_uid, _json]] call EFUNC(extension,extCall); + private _uid = _actor getOrDefault ["uid", ""]; + private _name = _actor getOrDefault ["name", ""]; + private _position = _actor getOrDefault ["position", []]; + private _direction = _actor getOrDefault ["direction", 0]; + private _stance = _actor getOrDefault ["stance", ""]; + private _rank = _actor getOrDefault ["rank", ""]; + private _state = _actor getOrDefault ["state", ""]; + private _phone_number = _actor getOrDefault ["phone_number", ""]; + private _email = _actor getOrDefault ["email", ""]; + private _organization = _actor getOrDefault ["organization", ""]; - private _phone_number = _finalActor getOrDefault ["phone_number", ""]; - private _email = _finalActor getOrDefault ["email", ""]; - - ["INFO", format ["New player %1 registered with phone number: %2, email: %3", _uid, _phone_number, _email], nil, nil] call EFUNC(common,log); - } else { - private _existingActor = _self call ["fetch", [_uid]]; - _finalActor = _existingActor; - - { - if !(_x in _finalActor) then { _finalActor set [_x, _y]; }; - } forEach _defaultActor; + [_uid, _name, _position, _direction, _stance, _rank, _state, _phone_number, _email, _organization] try { + if (_uid isEqualTo "" || !(_uid isEqualType "")) then { throw "Invalid UID!"; }; + if (_name isEqualTo "" || !(_name isEqualType "")) then { throw "Invalid Name!"; }; + if (_position isEqualTo [] || !(_position isEqualType [])) then { throw "Invalid Position!"; }; + if (_direction < 0 || !(_direction isEqualType 0)) then { throw "Invalid Direction!"; }; + if (_stance isEqualTo "" || !(_stance isEqualType "")) then { throw "Invalid Stance!"; }; + if (_rank isEqualTo "" || !(_rank isEqualType "")) then { throw "Invalid Rank!"; }; + if (_state isEqualTo "" || !(_state isEqualType "")) then { throw "Invalid State!"; }; + if (_phone_number isEqualTo "" || !(_phone_number isEqualType "")) then { throw "Invalid Phone Number!"; }; + if (_email isEqualTo "" || !(_email isEqualType "")) then { throw "Invalid Email!"; }; + if (_organization isEqualTo "" || !(_organization isEqualType "")) then { throw "Invalid Organization!"; }; + } catch { + ["ERROR", format ["Failed to validate actor %1!", _exception]] call EFUNC(common,log); + false }; - GVAR(ActorRegistry) set [_uid, _finalActor]; - - private _player = [_uid] call EFUNC(common,getPlayer); - [CRPC(actor,responseInitActor), [_finalActor], _player] call CFUNC(targetEvent); - - _finalActor + true }] ]]; -SETMVAR(FORGE_ActorStore,GVAR(ActorStore)); +GVAR(ActorBaseStore) = compileFinal createHashMapFromArray [ + ["#base", EGVAR(common,BaseStore)], + ["#type", "ActorBaseStore"], + ["#create", compileFinal { + GVAR(Registry) = createHashMap; + ["INFO", "Actor Store Initialized!"] call EFUNC(common,log); + }], + ["init", compileFinal { + params [["_uid", "", [""]]]; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _cached = GVAR(Registry) getOrDefault [_uid, nil]; + if !(isNil { _cached }) exitWith { [CRPC(actor,responseInitActor), [_cached], _player] call CFUNC(targetEvent); _cached }; + + ["actor:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", format ["Failed to check if actor %1 exists! Using fallback actor.", _uid]] call EFUNC(common,log); + + private _fallbackActor = GVAR(ActorModel) call ["fromPlayer", [_player]]; + _fallbackActor set ["uid", _uid]; + _fallbackActor = GVAR(ActorModel) call ["migrate", [_fallbackActor]]; + + GVAR(Registry) set [_uid, _fallbackActor]; + [CRPC(actor,responseInitActor), [_fallbackActor], _player] call CFUNC(targetEvent); + + _fallbackActor + }; + + private _finalActor = createHashMap; + + if (_result == "true") then { + _finalActor = _self call ["fetch", ["actor:get", _uid]]; + ["INFO", format ["Found actor for %1", _uid]] call EFUNC(common,log); + } else { + _finalActor = GVAR(ActorModel) call ["fromPlayer", [_player]]; + _finalActor set ["uid", _uid]; + + private _json = _self call ["toJSON", [_finalActor]]; + ["actor:create", [_uid, _json]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", format ["Failed to create actor %1! Using fallback actor.", _uid]] call EFUNC(common,log); + + _finalActor = GVAR(ActorModel) call ["migrate", [_finalActor]]; + GVAR(Registry) set [_uid, _finalActor]; + [CRPC(actor,responseInitActor), [_finalActor], _player] call CFUNC(targetEvent); + + _finalActor + }; + + ["INFO", format ["Created new actor for %1", _uid]] call EFUNC(common,log); + }; + + _finalActor = GVAR(ActorModel) call ["migrate", [_finalActor]]; + GVAR(Registry) set [_uid, _finalActor]; + + [CRPC(actor,responseInitActor), [_finalActor], _player] call CFUNC(targetEvent); + _finalActor + }], + ["snapshot", compileFinal { + params [["_uid", "", [""]]]; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _existing = GVAR(Registry) getOrDefault [_uid, createHashMap]; + private _finalActor = +_existing; + + if (_finalActor isEqualTo createHashMap) then { + _finalActor = GVAR(ActorModel) call ["defaults", []]; + _finalActor set ["uid", _uid]; + }; + + if (_player isNotEqualTo objNull) then { + _finalActor set ["uid", _uid]; + _finalActor set ["name", name _player]; + _finalActor set ["position", getPosASL _player]; + _finalActor set ["direction", getDir _player]; + _finalActor set ["stance", stance _player]; + _finalActor set ["rank", rank _player]; + _finalActor set ["state", lifeState _player]; + _finalActor set ["loadout", getUnitLoadout _player]; + } else { + ["WARNING", format ["No player object found for %1 during actor snapshot, using cached values.", _uid]] call EFUNC(common,log); + }; + + _finalActor = GVAR(ActorModel) call ["migrate", [_finalActor]]; + GVAR(Registry) set [_uid, _finalActor]; + + _finalActor + }] +]; + +GVAR(ActorStore) = createHashMapObject [GVAR(ActorBaseStore)]; GVAR(ActorStore) diff --git a/arma/server/addons/bank/README.md b/arma/server/addons/bank/README.md index 3617706..ab01bfc 100644 --- a/arma/server/addons/bank/README.md +++ b/arma/server/addons/bank/README.md @@ -1,4 +1,3 @@ -forge_server_bank -=================== +# forge_server_bank Description for this addon diff --git a/arma/server/addons/bank/XEH_PREP.hpp b/arma/server/addons/bank/XEH_PREP.hpp index 5dbb105..fae036d 100644 --- a/arma/server/addons/bank/XEH_PREP.hpp +++ b/arma/server/addons/bank/XEH_PREP.hpp @@ -1 +1,2 @@ +PREP(initBank); PREP(initBankStore); diff --git a/arma/server/addons/bank/XEH_postInit.sqf b/arma/server/addons/bank/XEH_postInit.sqf index 421c54b..db7687a 100644 --- a/arma/server/addons/bank/XEH_postInit.sqf +++ b/arma/server/addons/bank/XEH_postInit.sqf @@ -1 +1,3 @@ #include "script_component.hpp" + +call FUNC(initBank); diff --git a/arma/server/addons/bank/XEH_preInit.sqf b/arma/server/addons/bank/XEH_preInit.sqf index f94f7ed..b652346 100644 --- a/arma/server/addons/bank/XEH_preInit.sqf +++ b/arma/server/addons/bank/XEH_preInit.sqf @@ -7,34 +7,32 @@ PREP_RECOMPILE_END; // private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)]; [QGVAR(requestInitBank), { - params [["_uid", "", [""]], ["_bank", createHashMap, [createHashMap]]]; + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID!" }; - if (_bank isEqualTo createHashMap) exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid Bank data!" }; - - GVAR(BankStore) call ["init", [_uid, _bank]]; + GVAR(BankStore) call ["init", [_uid]]; }] call CFUNC(addEventHandler); [QGVAR(requestGetBank), { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + params [["_uid", "", [""]], ["_field", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid Session!" }; + private _finalData = GVAR(BankStore) call ["get", [GVAR(Registry), _uid, _field]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(BankStore) call ["get", [_uid, _sync]]; + [CRPC(bank,responseSyncBank), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestSetBank), { - params [["_uid", "", [""]], ["_key", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; + params [["_uid", "", [""]], ["_field", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; - if (_uid isEqualTo "" || _key isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID or Key!" }; + if (_uid isEqualTo "" || _field isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID or Key!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid Session!" }; + private _hashMap = GVAR(BankStore) call ["set", [GVAR(Registry), "bank:update", _uid, _field, _value, _sync]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(BankStore) call ["set", [_uid, _key, _value, _sync]]; + [CRPC(bank,responseSyncBank), [_hashMap], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestMSetBank), { @@ -43,42 +41,44 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID!" }; if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid field pairs!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid Session!" }; + private _hashMap = GVAR(BankStore) call ["mset", [GVAR(Registry), "bank:update", _uid, _fieldValuePairs, _sync]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(BankStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; + [CRPC(bank,responseSyncBank), [_hashMap], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestSaveBank), { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid Session!" }; + private _finalData = GVAR(BankStore) call ["save", [GVAR(Registry), "bank:update", _uid]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(BankStore) call ["save", [_uid, _sync]]; + [CRPC(bank,responseSyncBank), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestRemoveBank), { params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID!" }; - - GVAR(BankStore) call ["remove", [_uid]]; + GVAR(BankStore) call ["remove", [GVAR(Registry), _uid]]; }] call CFUNC(addEventHandler); [QGVAR(requestDeposit), { params [["_uid", "", [""]], ["_amount", 0, [0]]]; if (_uid isEqualTo "" || _amount isEqualTo 0) exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID or Amount!" }; - - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid Session!" }; - GVAR(BankStore) call ["deposit", [_uid, _amount]]; }] call CFUNC(addEventHandler); +[QGVAR(requestPayment), { + params [["_uid", "", [""]], ["_amount", 0, [0]]]; + + if (_uid isEqualTo "" || _amount isEqualTo 0) exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID or Amount!" }; + GVAR(BankStore) call ["payment", [_uid, _amount]]; +}] call CFUNC(addEventHandler); + [QGVAR(requestTransfer), { params [["_uid", "", [""]], ["_target", "", [""]], ["_from", "", [""]], ["_amount", 0, [0]]]; @@ -86,16 +86,13 @@ PREP_RECOMPILE_END; diag_log "[FORGE:Server:Bank] Empty/Invalid UID, Target, From Account, or Amount!" }; - // Prevent self-transfers (security check) if (_uid isEqualTo _target) exitWith { diag_log format ["[FORGE:Server:Bank] SECURITY: Player %1 attempted self-transfer!", _uid]; + private _player = [_uid] call EFUNC(common,getPlayer); [CRPC(notifications,recieveNotification), ["error", "Bank", "Cannot transfer to yourself!"], _player] call CFUNC(targetEvent); }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid Session!" }; - GVAR(BankStore) call ["transfer", [_uid, _target, _from, _amount]]; }] call CFUNC(addEventHandler); @@ -103,9 +100,12 @@ PREP_RECOMPILE_END; params [["_uid", "", [""]], ["_amount", 0, [0]]]; if (_uid isEqualTo "" || _amount isEqualTo 0) exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID or Amount!" }; - - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid Session!" }; - GVAR(BankStore) call ["withdraw", [_uid, _amount]]; }] call CFUNC(addEventHandler); + +[QGVAR(requestDepositEarnings), { + params [["_uid", "", [""]], ["_amount", 0, [0]]]; + + if (_uid isEqualTo "" || _amount isEqualTo 0) exitWith { diag_log "[FORGE:Server:Bank] Empty/Invalid UID or Amount!" }; + GVAR(BankStore) call ["depositEarnings", [_uid, _amount]]; +}] call CFUNC(addEventHandler); diff --git a/arma/server/addons/bank/XEH_preStart.sqf b/arma/server/addons/bank/XEH_preStart.sqf index 0228885..a51262a 100644 --- a/arma/server/addons/bank/XEH_preStart.sqf +++ b/arma/server/addons/bank/XEH_preStart.sqf @@ -1,3 +1,2 @@ #include "script_component.hpp" - #include "XEH_PREP.hpp" diff --git a/arma/server/addons/bank/functions/fnc_initBank.sqf b/arma/server/addons/bank/functions/fnc_initBank.sqf new file mode 100644 index 0000000..a4800b1 --- /dev/null +++ b/arma/server/addons/bank/functions/fnc_initBank.sqf @@ -0,0 +1,49 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initBank.sqf + * Author: IDSolutions + * Date: 2025-12-17 + * Last Update: 2026-02-17 + * Public: No + * + * Description: + * Initializes all editor-placed banks. + * + * Arguments: + * None + * + * Return Value: + * None + * + * Example: + * call forge_server_bank_fnc_initBank + */ + +private _atms = (allVariables missionNamespace) select { + private _var = missionNamespace getVariable _x; + ("atm" in _x) && { _var isEqualType objNull } && { !isNull _var } +}; + +private _banks = (allVariables missionNamespace) select { + private _var = missionNamespace getVariable _x; + ("bank" in _x) && { _var isEqualType objNull } && { !isNull _var } +}; + +if (_atms isNotEqualTo []) then { + { + private _atm = missionNamespace getVariable _x; + SETPVAR(_atm,isAtm,true); + } forEach _atms; +} else { + ["INFO", "No editor-placed atms found."] call EFUNC(common,log); +}; + +if (_banks isNotEqualTo []) then { + { + private _bank = missionNamespace getVariable _x; + SETPVAR(_bank,isBank,true); + } forEach _banks; +} else { + ["INFO", "No editor-placed banks found."] call EFUNC(common,log); +}; diff --git a/arma/server/addons/bank/functions/fnc_initBankStore.sqf b/arma/server/addons/bank/functions/fnc_initBankStore.sqf index af9d3ea..f130777 100644 --- a/arma/server/addons/bank/functions/fnc_initBankStore.sqf +++ b/arma/server/addons/bank/functions/fnc_initBankStore.sqf @@ -1,145 +1,326 @@ #include "..\script_component.hpp" /* + * File: fnc_initBankStore.sqf * Author: IDSolutions - * Initializes the bank store. + * Date: 2025-12-17 + * Last Update: 2026-02-17 + * Public: Yes + * + * Description: + * Initializes the bank store for managing player bank accounts. + * Provides methods for syncing, saving, and applying bank accounts to the player. * * Arguments: * None * * Return Value: - * None + * Bank store object [HASHMAP OBJECT] * - * Examples: - * [] call forge_server_bank_fnc_initBankStore - * - * Public: Yes + * Example: + * call forge_server_bank_fnc_initBankStore */ #pragma hemtt ignore_variables ["_self"] -GVAR(BankStore) = createHashMapObject [[ - ["#base", EGVAR(common,BaseStore)], - ["#type", "IBankStore"], - ["#create", { - GVAR(BankRegistry) = createHashMap; - GVAR(NameRegistry) = createHashMap; +GVAR(BankModel) = compileFinal createHashMapObject [[ + ["#type", "BankModel"], + ["defaults", compileFinal { + private _account = createHashMap; - _self set ["_registry", GVAR(BankRegistry)]; - _self set ["_extCallPrefix", "bank"]; - _self set ["_readMethod", "get"]; - _self set ["_storeName", "Bank"]; - _self set ["_syncEventName", CRPC(bank,responseSyncBank)]; + _account set ["uid", ""]; + _account set ["name", ""]; + _account set ["bank", 0]; + _account set ["cash", 0]; + _account set ["earnings", 0]; + _account set ["pin", 1234]; + _account set ["transactions", []]; - ["INFO", "Bank Store Initialized!", nil, nil] call EFUNC(common,log); + _account }], - ["init", { - params [["_uid", "", [""]], ["_defaultAccount", createHashMap, [createHashMap]]]; + ["fromPlayer", compileFinal { + params [["_player", objNull, [objNull]]]; + + if (_player isEqualTo objNull) exitWith { _self call ["defaults", []] }; + + private _account = _self call ["defaults", []]; + + _account set ["uid", getPlayerUID _player]; + _account set ["name", name _player]; + _account set ["bank", 0]; + _account set ["cash", 0]; + _account set ["earnings", 0]; + _account set ["pin", 1234]; + _account set ["transactions", []]; + + _account + }], + ["migrate", compileFinal { + params [["_account", createHashMap, [createHashMap]]]; + + private _defaults = _self call ["defaults", []]; + + { + if !(_x in _account) then { _account set [_x, _y]; }; + } forEach _defaults; + + _account + }], + ["validate", compileFinal { + params [["_account", createHashMap, [createHashMap]]]; + + private _uid = _account getOrDefault ["uid", ""]; + private _name = _account getOrDefault ["name", ""]; + private _bank = _account getOrDefault ["bank", 0]; + private _cash = _account getOrDefault ["cash", 0]; + private _earnings = _account getOrDefault ["earnings", 0]; + private _pin = _account getOrDefault ["pin", 1234]; + + [_uid, _name, _bank, _cash, _earnings, _pin] try { + if (_uid isEqualTo "" || !(_uid isEqualType "")) then { throw "Invalid UID!"; }; + if (_name isEqualTo "" || !(_name isEqualType "")) then { throw "Invalid Name!"; }; + if (_bank < 0 || !(_bank isEqualType 0)) then { throw "Invalid Bank!"; }; + if (_cash < 0 || !(_cash isEqualType 0)) then { throw "Invalid Cash!"; }; + if (_earnings < 0 || !(_earnings isEqualType 0)) then { throw "Invalid Earnings!"; }; + if (_pin < 1000 || _pin > 9999 || !(_pin isEqualType 0)) then { throw "Invalid Pin!"; }; + } catch { + ["ERROR", format ["Failed to validate account %1!", _exception]] call EFUNC(common,log); + false + }; + + true + }] +]]; + +GVAR(BankBaseStore) = compileFinal createHashMapFromArray [ + ["#base", EGVAR(common,BaseStore)], + ["#type", "BankBaseStore"], + ["#create", compileFinal { + GVAR(IndexRegistry) = createHashMap; + GVAR(Registry) = createHashMap; + ["INFO", "Bank Store Initialized!"] call EFUNC(common,log); + }], + ["init", compileFinal { + params [["_uid", "", [""]]]; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _cached = GVAR(Registry) getOrDefault [_uid, nil]; + if !(isNil { _cached }) exitWith { [CRPC(bank,responseInitBank), [_cached], _player] call CFUNC(targetEvent); _cached }; + + ["bank:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", format ["Failed to check if bank account %1 exists! Using fallback account.", _uid]] call EFUNC(common,log); + + private _fallbackAccount = GVAR(BankModel) call ["fromPlayer", [_player]]; + _fallbackAccount set ["uid", _uid]; + + private _regEntry = createHashMapFromArray [["uid", _uid], ["name", (name _player)]]; + GVAR(IndexRegistry) set [_uid, _regEntry]; + + GVAR(Registry) set [_uid, _fallbackAccount]; + [CRPC(bank,responseInitBank), [_fallbackAccount], _player] call CFUNC(targetEvent); + + _fallbackAccount + }; private _finalAccount = createHashMap; - ["bank:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - private _exists = _result == "true"; - - if !(_exists) then { - _finalAccount = _defaultAccount; + if (_result == "true") then { + _finalAccount = _self call ["fetch", ["bank:get", _uid]]; + ["INFO", format ["Found bank account for %1", _uid]] call EFUNC(common,log); + } else { + _finalAccount = GVAR(BankModel) call ["fromPlayer", [_player]]; _finalAccount set ["uid", _uid]; private _json = _self call ["toJSON", [_finalAccount]]; - ["bank:create", [_uid, _json]] call EFUNC(extension,extCall); + ["bank:create", [_uid, _json]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", format ["Failed to create bank account %1! Using fallback account.", _uid]] call EFUNC(common,log); - ["INFO", format ["Created new bank account for %1", _uid], nil, nil] call EFUNC(common,log); - } else { - private _existingAccount = _self call ["fetch", [_uid]]; - _finalAccount = _existingAccount; + private _regEntry = createHashMapFromArray [["uid", _uid], ["name", (name _player)]]; + GVAR(IndexRegistry) set [_uid, _regEntry]; - { - if !(_x in _finalAccount) then { _finalAccount set [_x, _y]; }; - } forEach _defaultAccount; + GVAR(Registry) set [_uid, _finalAccount]; + [CRPC(bank,responseInitBank), [_finalAccount], _player] call CFUNC(targetEvent); - ["INFO", format ["Found bank account for %1", _uid], nil, nil] call EFUNC(common,log); + _finalAccount + }; + + ["INFO", format ["Created new bank account for %1", _uid]] call EFUNC(common,log); }; - GVAR(BankRegistry) set [_uid, _finalAccount, true]; - private _player = [_uid] call EFUNC(common,getPlayer); private _regEntry = createHashMapFromArray [["uid", _uid], ["name", (name _player)]]; - GVAR(NameRegistry) set [_uid, _regEntry]; + GVAR(IndexRegistry) set [_uid, _regEntry]; + + // _finalAccount = GVAR(BankModel) call ["migrate", [_finalAccount]]; + GVAR(Registry) set [_uid, _finalAccount]; [CRPC(bank,responseInitBank), [_finalAccount], _player] call CFUNC(targetEvent); _finalAccount }], - ["deposit", { + ["deposit", compileFinal { params [["_uid", "", [""]], ["_amount", 0, [0]]]; - ["INFO", format ["Deposit %1, for %2", _amount, _uid], nil, nil] call EFUNC(common,log); + ["INFO", format ["Deposit %1, for %2", _amount, _uid]] call EFUNC(common,log); - private _account = GVAR(BankRegistry) getOrDefault [_uid, nil]; - if (isNil "_account") exitWith { ["ERROR", "Empty/Invalid Account!", nil, nil] call EFUNC(common,log); }; + private _account = GVAR(Registry) getOrDefault [_uid, nil]; + if (isNil "_account") exitWith { ["ERROR", "Empty/Invalid Account!"] call EFUNC(common,log); }; private _bank = _account getOrDefault ["bank", 0]; private _cash = _account getOrDefault ["cash", 0]; - if (_cash < _amount) exitWith { ["WARNING", "Insufficient Funds!", nil, nil] call EFUNC(common,log); }; - - private _finalAccount = createHashMapFromArray [ - ["bank", (_bank + _amount)], - ["cash", (_cash - _amount)] - ]; - _self call ["mset", [_uid, _finalAccount]]; + if (_cash < _amount) exitWith { ["WARNING", "Insufficient Funds!"] call EFUNC(common,log); }; + private _finalAccount = createHashMapFromArray [["bank", (_bank + _amount)], ["cash", (_cash - _amount)]]; private _player = [_uid] call EFUNC(common,getPlayer); + + GVAR(Registry) set [_uid, _finalAccount]; + + [CRPC(bank,responseSyncBank), [_finalAccount], _player] call CFUNC(targetEvent); [CRPC(notifications,recieveNotification), ["info", "Bank", format ["Deposited $%1", _amount]], _player] call CFUNC(targetEvent); }], - ["transfer", { - params [["_uid", "", [""]], ["_target", "", [""]], ["_from", "", [""]], ["_amount", 0, [0]]]; + ["payment", compileFinal { + params [["_uid", "", [""]], ["_amount", 0, [0]]]; - if (_uid isEqualTo _target) exitWith { - ["WARNING", format ["Self-transfer attempt blocked for %1", _uid], nil, nil] call EFUNC(common,log); + ["INFO", format ["Payment %1, for %2", _amount, _uid]] call EFUNC(common,log); + + private _account = GVAR(Registry) getOrDefault [_uid, nil]; + if (isNil "_account") exitWith { ["ERROR", "Empty/Invalid Account!"] call EFUNC(common,log); }; + + private _bank = _account getOrDefault ["bank", 0]; + private _finalAccount = createHashMapFromArray [["bank", (_bank + _amount)]]; + private _player = [_uid] call EFUNC(common,getPlayer); + + GVAR(Registry) set [_uid, _finalAccount]; + + [CRPC(bank,responseSyncBank), [_finalAccount], _player] call CFUNC(targetEvent); + [CRPC(notifications,recieveNotification), ["info", "Bank", format ["Paid $%1", _amount]], _player] call CFUNC(targetEvent); + }], + ["buildChargeResult", compileFinal { + params [["_message", "Unable to process bank payment.", [""]]]; + + createHashMapFromArray [ + ["success", false], + ["message", _message], + ["patch", createHashMap] + ] + }], + ["chargeCheckout", compileFinal { + params [ + ["_uid", "", [""]], + ["_source", "cash", [""]], + ["_amount", 0, [0]], + ["_commit", false, [false]] + ]; + + private _result = _self call ["buildChargeResult", []]; + private _field = switch (toLowerANSI _source) do { + case "cash": { "cash" }; + case "bank": { "bank" }; + default { "" }; }; - private _account = GVAR(BankRegistry) getOrDefault [_uid, nil]; - if (isNil "_account") exitWith { ["ERROR", "Empty/Invalid Account!", nil, nil] call EFUNC(common,log); }; + if (_field isEqualTo "") exitWith { + _result set ["message", "Selected bank payment source is unsupported."]; + _result + }; - private _targetAccount = GVAR(BankRegistry) getOrDefault [_target, nil]; - if (isNil "_targetAccount") exitWith { ["ERROR", "Empty/Invalid Target Account!", nil, nil] call EFUNC(common,log); }; + private _account = GVAR(Registry) getOrDefault [_uid, createHashMap]; + if (_account isEqualTo createHashMap) exitWith { + _result set ["message", "Bank account data is unavailable for checkout."]; + _result + }; - private _bank = _account getOrDefault [_from, 0]; - if (_bank < _amount) exitWith { ["WARNING", "Insufficient Funds!", nil, nil] call EFUNC(common,log); }; + private _balance = _account getOrDefault [_field, 0]; + if (_balance < _amount) exitWith { + private _message = [ + "Bank balance cannot cover this checkout.", + "Cash on hand cannot cover this checkout." + ] select (_field isEqualTo "cash"); + + _result set ["message", _message]; + _result + }; + + private _patch = createHashMapFromArray [[_field, (_balance - _amount)]]; + if (_commit) then { + _patch = _self call ["mset", [GVAR(Registry), "bank:update", _uid, _patch, false]]; + }; + + _result set ["success", true]; + _result set ["message", ""]; + _result set ["patch", _patch]; + _result + }], + ["transfer", compileFinal { + params [["_uid", "", [""]], ["_target", "", [""]], ["_from", "", [""]], ["_amount", 0, [0]]]; + + if (_uid isEqualTo _target) exitWith { ["WARNING", format ["Self-transfer attempt blocked for %1", _uid]] call EFUNC(common,log); }; + + private _account = GVAR(Registry) getOrDefault [_uid, nil]; + if (isNil "_account") exitWith { ["ERROR", "Empty/Invalid Account!"] call EFUNC(common,log); }; + + private _targetAccount = GVAR(Registry) getOrDefault [_target, nil]; + if (isNil "_targetAccount") exitWith { ["ERROR", "Empty/Invalid Target Account!"] call EFUNC(common,log); }; + + private _selected = _account getOrDefault [_from, 0]; + if (_selected < _amount) exitWith { ["WARNING", "Insufficient Funds!"] call EFUNC(common,log); }; private _targetBank = _targetAccount getOrDefault ["bank", 0]; - private _finalAccount = createHashMapFromArray [[_from, (_bank - _amount)]]; + private _finalAccount = createHashMapFromArray [[_from, (_selected - _amount)]]; private _finalTargetBank = createHashMapFromArray [["bank", (_targetBank + _amount)]]; - _self call ["mset", [_uid, _finalAccount]]; - _self call ["mset", [_target, _finalTargetBank]]; + GVAR(Registry) set [_uid, _finalAccount]; + GVAR(Registry) set [_target, _finalTargetBank]; private _player = [_uid] call EFUNC(common,getPlayer); private _targetPlayer = [_target] call EFUNC(common,getPlayer); + [CRPC(bank,responseSyncBank), [_finalAccount], _player] call CFUNC(targetEvent); + [CRPC(bank,responseSyncBank), [_finalTargetBank], _targetPlayer] call CFUNC(targetEvent); [CRPC(notifications,recieveNotification), ["info", "Bank", format ["Transferred $%1 to %2", _amount, (name _targetPlayer)]], _player] call CFUNC(targetEvent); [CRPC(notifications,recieveNotification), ["info", "Bank", format ["Received $%1 from %2", _amount, (name _player)]], _targetPlayer] call CFUNC(targetEvent); }], - ["withdraw", { + ["withdraw", compileFinal { params [["_uid", "", [""]], ["_amount", 0, [0]]]; - ["INFO", format ["Withdraw %1, for %2", _amount, _uid], nil, nil] call EFUNC(common,log); + ["INFO", format ["Withdraw %1, for %2", _amount, _uid]] call EFUNC(common,log); - private _account = GVAR(BankRegistry) getOrDefault [_uid, nil]; - if (isNil "_account") exitWith { ["ERROR", "Empty/Invalid Account!", nil, nil] call EFUNC(common,log); }; + private _account = GVAR(Registry) getOrDefault [_uid, nil]; + if (isNil "_account") exitWith { ["ERROR", "Empty/Invalid Account!"] call EFUNC(common,log); }; private _bank = _account getOrDefault ["bank", 0]; private _cash = _account getOrDefault ["cash", 0]; - if (_bank < _amount) exitWith { ["WARNING", "Insufficient Funds!", nil, nil] call EFUNC(common,log); }; - - private _finalAccount = createHashMapFromArray [ - ["bank", (_bank - _amount)], - ["cash", (_cash + _amount)] - ]; - _self call ["mset", [_uid, _finalAccount]]; + if (_bank < _amount) exitWith { ["WARNING", "Insufficient Funds!"] call EFUNC(common,log); }; + private _finalAccount = createHashMapFromArray [["bank", (_bank - _amount)], ["cash", (_cash + _amount)]]; private _player = [_uid] call EFUNC(common,getPlayer); - [CRPC(notifications,recieveNotification), ["info", "Bank", format ["Withdrew $%1", _amount]], _player] call CFUNC(targetEvent); - }] -]]; -SETMVAR(FORGE_BankStore,GVAR(BankStore)); + GVAR(Registry) set [_uid, _finalAccount]; + + [CRPC(bank,responseSyncBank), [_finalAccount], _player] call CFUNC(targetEvent); + [CRPC(notifications,recieveNotification), ["info", "Bank", format ["Withdrew $%1", _amount]], _player] call CFUNC(targetEvent); + }], + ["depositEarnings", compileFinal { + params [["_uid", "", [""]], ["_amount", 0, [0]]]; + + ["INFO", format ["Deposit Earnings %1, for %2", _amount, _uid]] call EFUNC(common,log); + + private _account = GVAR(Registry) getOrDefault [_uid, nil]; + if (isNil "_account") exitWith { ["ERROR", "Empty/Invalid Account!"] call EFUNC(common,log); }; + + private _bank = _account getOrDefault ["bank", 0]; + private _earnings = _account getOrDefault ["earnings", 0]; + if (_earnings < _amount) exitWith { ["WARNING", "Insufficient Earnings!"] call EFUNC(common,log); }; + + private _finalAccount = createHashMapFromArray [["bank", (_bank + _amount)], ["earnings", (_earnings - _amount)]]; + private _player = [_uid] call EFUNC(common,getPlayer); + + GVAR(Registry) set [_uid, _finalAccount]; + + [CRPC(bank,responseSyncBank), [_finalAccount], _player] call CFUNC(targetEvent); + [CRPC(notifications,recieveNotification), ["info", "Bank", format ["Deposited $%1 from earnings", _amount]], _player] call CFUNC(targetEvent); + }] +]; + +GVAR(BankStore) = createHashMapObject [GVAR(BankBaseStore)]; GVAR(BankStore) diff --git a/arma/server/addons/common/README.md b/arma/server/addons/common/README.md index f6ac3d5..54ae201 100644 --- a/arma/server/addons/common/README.md +++ b/arma/server/addons/common/README.md @@ -1,4 +1,3 @@ -forge_server_common -=================== +# forge_server_common Common functionality shared between addons. diff --git a/arma/server/addons/common/XEH_PREP.hpp b/arma/server/addons/common/XEH_PREP.hpp index b74d96d..a5a2c37 100644 --- a/arma/server/addons/common/XEH_PREP.hpp +++ b/arma/server/addons/common/XEH_PREP.hpp @@ -1,7 +1,7 @@ +PREP(baseStore); PREP(formatNumber); PREP(getPlayer); PREP(generateHash); PREP(generateSecureData); -PREP(initBaseStore); PREP(log); PREP(timeToSeconds); diff --git a/arma/server/addons/common/XEH_preStart.sqf b/arma/server/addons/common/XEH_preStart.sqf index 0228885..a51262a 100644 --- a/arma/server/addons/common/XEH_preStart.sqf +++ b/arma/server/addons/common/XEH_preStart.sqf @@ -1,3 +1,2 @@ #include "script_component.hpp" - #include "XEH_PREP.hpp" diff --git a/arma/server/addons/common/functions/fnc_baseStore.sqf b/arma/server/addons/common/functions/fnc_baseStore.sqf new file mode 100644 index 0000000..585d3cc --- /dev/null +++ b/arma/server/addons/common/functions/fnc_baseStore.sqf @@ -0,0 +1,117 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_baseStore.sqf + * Author: IDSolutions + * Date: 2026-01-08 + * Last Update: 2026-02-13 + * Public: No + * + * Description: + * No description added yet. + * + * Arguments: + * None + * + * Return Value: + * Base store [HASHMAP] + * + * Example: + * call forge_x_component_fnc_myFunction + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(BaseStore) = compileFinal createHashMapFromArray [ + ["#type", "IBaseStore"], + ["fetch", { + params [["_function", "", [""]], ["_key", "", [""]]]; + + private _data = createHashMap; + + [_function, [_key]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + ["INFO", format ["Data: %1", _result]] call EFUNC(common,log); + + if (_result isNotEqualTo []) then { _data = _self call ["toHashMap", [_result]] }; + + _data + }], + ["get", { + params [["_registry", createHashMap, [createHashMap]], ["_key", "", [""]], ["_field", "", [""]]]; + + private _existingData = _registry get _key; + private _finalData = createHashMap; + + if (_field isNotEqualTo "") then { + _finalData = _existingData get _field + } else { + _finalData = _existingData + }; + + _finalData + }], + ["set", { + params [["_registry", createHashMap, [createHashMap]], ["_function", "", [""]], ["_key", "", [""]], ["_field", "", [""]], ["_value", nil, [0, "", [], false, createHashMap, objNull, grpNull]], ["_sync", false, [false]]]; + + private _existingData = _registry get _key; + private _finalData = +_existingData; + private _hashMap = createHashMap; + + _finalData set [_field, _value]; + _hashMap set [_field, _value]; + _registry set [_key, _finalData]; + + if (_sync) then { + private _json = _self call ["toJSON", [_hashMap]]; + [_function, [_key, _json]] call EFUNC(extension,extCall); + }; + + _hashMap + }], + ["mset", { + params [["_registry", createHashMap, [createHashMap]], ["_function", "", [""]], ["_key", "", [""]], ["_fieldValuePairs", createHashMap, [createHashMap]], ["_sync", false, [false]]]; + + private _existingData = _registry get _key; + private _finalData = +_existingData; + private _hashMap = createHashMap; + + { _finalData set [_x, _y]; } forEach _fieldValuePairs; + { _hashMap set [_x, _y]; } forEach _fieldValuePairs; + + _registry set [_key, _finalData]; + + if (_sync) then { + private _json = _self call ["toJSON", [_hashMap]]; + [_function, [_key, _json]] call EFUNC(extension,extCall); + }; + + _hashMap + }], + ["save", { + params [["_registry", createHashMap, [createHashMap]], ["_function", "", [""]], ["_key", "", [""]]]; + + private _existingData = _registry get _key; + private _finalData = +_existingData; + private _json = _self call ["toJSON", [_finalData]]; + + [_function, [_key, _json]] call EFUNC(extension,extCall); + + _finalData + }], + ["remove", { + params [["_registry", createHashMap, [createHashMap]], ["_key", "", [""]]]; + + _registry deleteAt _key; + }], + ["toHashMap", { + params [["_data", "", [""]]]; + + fromJSON _data + }], + ["toJSON", { + params [["_data", createHashMap, [createHashMap]]]; + + toJSON _data + }] +]; + +GVAR(BaseStore) diff --git a/arma/server/addons/common/functions/fnc_initBaseStore.sqf b/arma/server/addons/common/functions/fnc_initBaseStore.sqf deleted file mode 100644 index a8abf0e..0000000 --- a/arma/server/addons/common/functions/fnc_initBaseStore.sqf +++ /dev/null @@ -1,154 +0,0 @@ -#include "..\script_component.hpp" - -/* - * Author: IDSolutions - * Initializes the base store. - * - * Arguments: - * None - * - * Return Value: - * None - * - * Examples: - * [] call forge_server_common_fnc_initBaseStore - * - * Public: Yes - */ - -#pragma hemtt ignore_variables ["_self"] -GVAR(BaseStore) = createHashMapObject [[ - ["#type", "IBaseStore"], - ["fetch", { - params [["_uid", "", [""]]]; - - private _extCallPrefix = _self get "_extCallPrefix"; - private _readMethod = _self getOrDefault ["_readMethod", "get"]; - private _funcName = format ["%1:%2", _extCallPrefix, _readMethod]; - private _store = createHashMap; - - [_funcName, [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - ["INFO", format ["Data: %1", _result], nil, nil] call EFUNC(common,log); - - if (count _result > 0) then { _store = _self call ["toHashMap", [_result]]; }; - - _store - }], - ["get", { - params [["_uid", "", [""]], ["_sync", false, [false]]]; - - private _finalData = createHashMap; - private _registry = _self get "_registry"; - - if (_sync) then { - private _existingData = _self call ["fetch", [_uid]]; - - _finalData = _existingData; - _registry set [_uid, _finalData]; - } else { - _finalData = _registry get _uid; - }; - - _finalData - }], - ["set", { - params [["_uid", "", [""]], ["_field", "", [""]], ["_value", nil], ["_sync", false, [false]]]; - - private _registry = _self get "_registry"; - private _existingData = _registry get _uid; - private _finalData = +_existingData; - private _hashMap = createHashMap; - - _finalData set [_field, _value]; - _hashMap set [_field, _value]; - _registry set [_uid, _finalData]; - - if (_sync) then { - private _extCallPrefix = _self get "_extCallPrefix"; - private _funcName = format ["%1:update", _extCallPrefix]; - private _json = _self call ["toJSON", [_hashMap]]; - - [_funcName, [_uid, _json]] call EFUNC(extension,extCall); - }; - - private _syncEventName = _self getOrDefault ["_syncEventName", ""]; - if (_syncEventName isNotEqualTo "") then { - private _player = [_uid] call EFUNC(common,getPlayer); - [_syncEventName, [_hashMap], _player] call CFUNC(targetEvent); - }; - - _hashMap - }], - ["mset", { - params [["_uid", "", [""]], ["_fieldValuePairs", createHashMap, [createHashMap]], ["_sync", false, [false]]]; - - private _registry = _self get "_registry"; - private _existingData = _registry get _uid; - private _finalData = +_existingData; - private _hashMap = createHashMap; - - { _finalData set [_x, _y]; } forEach _fieldValuePairs; - { _hashMap set [_x, _y]; } forEach _fieldValuePairs; - - _registry set [_uid, _finalData]; - - if (_sync) then { - private _extCallPrefix = _self get "_extCallPrefix"; - private _funcName = format ["%1:update", _extCallPrefix]; - private _json = _self call ["toJSON", [_hashMap]]; - - [_funcName, [_uid, _json]] call EFUNC(extension,extCall); - }; - - private _syncEventName = _self getOrDefault ["_syncEventName", ""]; - if (_syncEventName isNotEqualTo "") then { - private _player = [_uid] call EFUNC(common,getPlayer); - [_syncEventName, [_hashMap], _player] call CFUNC(targetEvent); - }; - - _hashMap - }], - ["save", { - params [["_uid", "", [""]], ["_sync", false, [false]]]; - - private _registry = _self get "_registry"; - private _existingData = _registry get _uid; - private _finalData = +_existingData; - private _extCallPrefix = _self get "_extCallPrefix"; - private _funcName = format ["%1:update", _extCallPrefix]; - private _json = _self call ["toJSON", [_finalData]]; - - [_funcName, [_uid, _json]] call EFUNC(extension,extCall); - - if (_sync) then { - private _syncEventName = _self getOrDefault ["_syncEventName", ""]; - if (_syncEventName isNotEqualTo "") then { - private _player = [_uid] call EFUNC(common,getPlayer); - [_syncEventName, [_finalData], _player] call CFUNC(targetEvent); - }; - }; - - _finalData - }], - ["remove", { - params [["_uid", "", [""]]]; - - private _registry = _self get "_registry"; - _registry deleteAt _uid; - }], - ["toHashMap", { - params [["_data", "", [""]]]; - - private _hashMap = fromJSON _data; - _hashMap - }], - ["toJSON", { - params [["_data", createHashMap, [createHashMap]]]; - - private _json = toJSON _data; - _json - }] -]]; - -SETMVAR(FORGE_BaseStore,GVAR(BaseStore)); -GVAR(BaseStore) diff --git a/arma/server/addons/common/functions/fnc_log.sqf b/arma/server/addons/common/functions/fnc_log.sqf index e14272e..3ec4d09 100644 --- a/arma/server/addons/common/functions/fnc_log.sqf +++ b/arma/server/addons/common/functions/fnc_log.sqf @@ -4,7 +4,7 @@ * File: fnc_log.sqf * Author: IDSolutions * Date: 2026-01-03 - * Last Update: 2026-01-03 + * Last Update: 2026-01-18 * Public: No * * Description: @@ -35,19 +35,21 @@ if (_stackTrace) then { _message = _traceText; }; -private _timestamp = format (["%1-%2-%3 %4:%5:%6:%7"] + systemTimeUTC); +// private _timestamp = format (["%1-%2-%3 %4:%5:%6:%7"] + systemTimeUTC); if (isNil "_file") then { _file = ["", _fnc_scriptName] select (!isNil "_fnc_scriptName"); }; if (isNil "_callingFile" && !isNil "_fnc_scriptNameParent") then { _callingFile = _fnc_scriptNameParent; }; -private _callingFileText = if !(isNil "_callingFile") then { format ["Called By: %1 |", _callingFile] } else { "" }; +// private _callingFileText = if !(isNil "_callingFile") then { format ["Called By: %1", _callingFile] } else { "" }; -diag_log text format [ - "%1 | %2 | %3 | File: %4 | %5 | %6", - _timestamp, - _identifier, - _logLevel, - _file, - _callingFileText, - _message -]; +// diag_log text format [ +// "%1 | %2 | %3 | File: %4 | %5 | %6", +// _timestamp, +// _identifier, +// _logLevel, +// _file, +// _callingFileText, +// _message +// ]; + +diag_log text format ["[%1] %2: %3", _identifier, _logLevel, _message]; diff --git a/arma/server/addons/economy/README.md b/arma/server/addons/economy/README.md index f97b17f..2522c25 100644 --- a/arma/server/addons/economy/README.md +++ b/arma/server/addons/economy/README.md @@ -1,4 +1,3 @@ -forge_server_economy -=================== +# forge_server_economy Description for this addon diff --git a/arma/server/addons/economy/XEH_postInit.sqf b/arma/server/addons/economy/XEH_postInit.sqf index f10b958..b912379 100644 --- a/arma/server/addons/economy/XEH_postInit.sqf +++ b/arma/server/addons/economy/XEH_postInit.sqf @@ -1,18 +1,3 @@ #include "script_component.hpp" GVAR(MEconomyStore) call ["init", []]; - -[QGVAR(onKilled), { - params ["_unit"]; - GVAR(MEconomyStore) call ["onKilled", [_unit]]; -}] call CFUNC(addEventHandler); - -[QGVAR(onRespawn), { - params ["_unit", "_corpse", "_uid"]; - GVAR(MEconomyStore) call ["onRespawn", [_unit, _corpse, _uid]]; -}] call CFUNC(addEventHandler); - -[QGVAR(onHealed), { - params ["_unit"]; - GVAR(MEconomyStore) call ["onHealed", [_unit]]; -}] call CFUNC(addEventHandler); diff --git a/arma/server/addons/economy/XEH_preInit.sqf b/arma/server/addons/economy/XEH_preInit.sqf index e66ae75..8a385c3 100644 --- a/arma/server/addons/economy/XEH_preInit.sqf +++ b/arma/server/addons/economy/XEH_preInit.sqf @@ -6,9 +6,9 @@ PREP_RECOMPILE_END; // private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)]; -if (isNil QGVAR(MEconomyStore)) then { [] call FUNC(initMEconomyStore); }; -if (isNil QGVAR(FEconomyStore)) then { [] call FUNC(initFEconomyStore); }; -// if (isNil QGVAR(SEconomyStore)) then { [] call FUNC(initSEconomyStore); }; +if (isNil QGVAR(MEconomyStore)) then { call FUNC(initMEconomyStore); }; +if (isNil QGVAR(FEconomyStore)) then { call FUNC(initFEconomyStore); }; +// if (isNil QGVAR(SEconomyStore)) then { call FUNC(initSEconomyStore); }; [QGVAR(FuelStart), { params ["_source", "_target", "_unit"]; @@ -27,3 +27,18 @@ if (isNil QGVAR(FEconomyStore)) then { [] call FUNC(initFEconomyStore); }; params ["_source", "_target"]; GVAR(FEconomyStore) call ["stop", [_source, _target]]; }] call CFUNC(addEventHandler); + +[QGVAR(onKilled), { + params ["_unit"]; + GVAR(MEconomyStore) call ["onKilled", [_unit]]; +}] call CFUNC(addEventHandler); + +[QGVAR(onRespawn), { + params ["_unit", "_corpse", "_uid"]; + GVAR(MEconomyStore) call ["onRespawn", [_unit, _corpse, _uid]]; +}] call CFUNC(addEventHandler); + +[QGVAR(onHealed), { + params ["_unit"]; + GVAR(MEconomyStore) call ["onHealed", [_unit]]; +}] call CFUNC(addEventHandler); diff --git a/arma/server/addons/economy/XEH_preStart.sqf b/arma/server/addons/economy/XEH_preStart.sqf index 0228885..a51262a 100644 --- a/arma/server/addons/economy/XEH_preStart.sqf +++ b/arma/server/addons/economy/XEH_preStart.sqf @@ -1,3 +1,2 @@ #include "script_component.hpp" - #include "XEH_PREP.hpp" diff --git a/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf b/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf index 7a56360..cbc2d91 100644 --- a/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf +++ b/arma/server/addons/economy/functions/fnc_initMEconomyStore.sqf @@ -4,7 +4,7 @@ * File: fnc_initMEconomyStore.sqf * Author: IDSolutions * Date: 2025-12-20 - * Last Update: 2026-01-03 + * Last Update: 2026-02-13 * Public: No * * Description: @@ -69,7 +69,7 @@ GVAR(MEconomyStore) = createHashMapObject [[ if (isNull _unit) exitWith { ["WARNING", format ["Invalid unit provided: %1", (name _unit)], nil, nil] call EFUNC(common,log); }; private _uid = getPlayerUID _unit; - private _account = EGVAR(bank,BankRegistry) get _uid; + private _account = EGVAR(bank,Registry) get _uid; if (isNil "_account") exitWith { ["ERROR", format ["No account found for %1. UID: %2", (name _unit), _uid], nil, nil] call EFUNC(common,log); }; diff --git a/arma/server/addons/extension/README.md b/arma/server/addons/extension/README.md index 067835e..bd27353 100644 --- a/arma/server/addons/extension/README.md +++ b/arma/server/addons/extension/README.md @@ -1,4 +1,3 @@ -forge_server_extension -=================== +# forge_server_extension Extension functionality shared between addons. diff --git a/arma/server/addons/extension/XEH_preStart.sqf b/arma/server/addons/extension/XEH_preStart.sqf index 0228885..a51262a 100644 --- a/arma/server/addons/extension/XEH_preStart.sqf +++ b/arma/server/addons/extension/XEH_preStart.sqf @@ -1,3 +1,2 @@ #include "script_component.hpp" - #include "XEH_PREP.hpp" diff --git a/arma/server/addons/extension/functions/fnc_extCall.sqf b/arma/server/addons/extension/functions/fnc_extCall.sqf index e56b7e4..b2bab27 100644 --- a/arma/server/addons/extension/functions/fnc_extCall.sqf +++ b/arma/server/addons/extension/functions/fnc_extCall.sqf @@ -25,6 +25,27 @@ params [["_function", "", [""]], ["_arguments", [], [[]]]]; ["INFO", format ["Calling function: %1", _function], nil, nil] call EFUNC(common,log); + +private _functionLower = toLower _function; +private _requiresRedis = !(_functionLower in ["status", "version"]) + && (_functionLower find "icom:" == 0) + && (_functionLower find "terrain:" == 0); + +if (_requiresRedis) then { + ("forge_server" callExtension ["status", []]) params ["_redisStatus", "_statusExtCode", "_statusArmaCode"]; + + private _statusSuccess = (_statusExtCode == 0) && (_statusArmaCode == 0 || _statusArmaCode == 301); + if (!_statusSuccess) exitWith { + ["WARNING", "Unable to determine Redis status before extension call", nil, nil] call EFUNC(common,log); + ["Error: Redis status check failed", false] + }; + + if (_redisStatus != "connected") exitWith { + ["WARNING", format ["Blocked extension call '%1' because Redis status is '%2'", _function, _redisStatus], nil, nil] call EFUNC(common,log); + [format ["Error: Redis is %1", _redisStatus], false] + }; +}; + ("forge_server" callExtension [_function, _arguments]) params ["_result", "_extCode", "_armaCode"]; private _success = true; diff --git a/arma/server/addons/garage/README.md b/arma/server/addons/garage/README.md index 5cfa273..6002948 100644 --- a/arma/server/addons/garage/README.md +++ b/arma/server/addons/garage/README.md @@ -1,4 +1,3 @@ -forge_server_garage -=================== +# forge_server_garage Description for this addon diff --git a/arma/server/addons/garage/XEH_postInit.sqf b/arma/server/addons/garage/XEH_postInit.sqf index 13676d7..c45c803 100644 --- a/arma/server/addons/garage/XEH_postInit.sqf +++ b/arma/server/addons/garage/XEH_postInit.sqf @@ -1,3 +1,3 @@ #include "script_component.hpp" -[] call FUNC(initGarage); +call FUNC(initGarage); diff --git a/arma/server/addons/garage/XEH_preInit.sqf b/arma/server/addons/garage/XEH_preInit.sqf index 0ab16a4..109ae47 100644 --- a/arma/server/addons/garage/XEH_preInit.sqf +++ b/arma/server/addons/garage/XEH_preInit.sqf @@ -7,21 +7,21 @@ PREP_RECOMPILE_END; // private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)]; [QGVAR(requestInitGarage), { - params [["_uid", "", [""]], ["_garage", createHashMap, [createHashMap]]]; + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID!" }; - GVAR(GarageStore) call ["init", [_uid, _garage]]; + GVAR(GarageStore) call ["init", [_uid]]; }] call CFUNC(addEventHandler); [QGVAR(requestGetGarage), { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + params [["_uid", "", [""]], ["_field", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid Session!" }; + private _finalData = GVAR(GarageStore) call ["get", [GVAR(Registry), "garage:get", _uid, _field]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(GarageStore) call ["get", [_uid, _sync]]; + [CRPC(garage,responseSyncGarage), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestSetGarage), { @@ -29,10 +29,10 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "" || _key isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID or Key!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid Session!" }; + private _hashMap = GVAR(GarageStore) call ["set", [GVAR(Registry), "garage:update", _uid, _key, _value, _sync]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(GarageStore) call ["set", [_uid, _key, _value, _sync]]; + [CRPC(garage,responseSyncGarage), [_hashMap], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestMSetGarage), { @@ -41,49 +41,124 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID!" }; if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid field pairs!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid Session!" }; + private _hashMap = GVAR(GarageStore) call ["mset", [GVAR(Registry), "garage:update", _uid, _fieldValuePairs, _sync]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(GarageStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; + [CRPC(garage,responseSyncGarage), [_hashMap], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestSaveGarage), { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid Session!" }; + private _finalData = GVAR(GarageStore) call ["save", [GVAR(Registry), "garage:update", _uid]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(GarageStore) call ["save", [_uid, _sync]]; + [CRPC(garage,responseSyncGarage), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestRemoveGarage), { params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Garage] Empty/Invalid UID!" }; + GVAR(GarageStore) call ["remove", [GVAR(Registry), _uid]]; +}] call CFUNC(addEventHandler); - GVAR(GarageStore) call ["remove", [_uid]]; +[QGVAR(requestStoreVehicle), { + params [ + ["_uid", "", [""]], + ["_className", "", [""]], + ["_fuel", 0, [0]], + ["_damage", 0, [0]], + ["_hitPointsJson", "", [""]] + ]; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_uid isEqualTo "" || { _className isEqualTo "" } || { _hitPointsJson isEqualTo "" }) exitWith { + [CRPC(garage,responseGarageAction), [createHashMapFromArray [ + ["action", "store"], + ["success", false], + ["message", "Missing vehicle data for garage storage."] + ]], _player] call CFUNC(targetEvent); + }; + + private _payloadJson = toJSON (createHashMapFromArray [ + ["classname", _className], + ["fuel", _fuel], + ["damage", _damage], + ["hit_points", fromJSON _hitPointsJson] + ]); + + ["garage:add", [_uid, _payloadJson]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + [CRPC(garage,responseGarageAction), [createHashMapFromArray [ + ["action", "store"], + ["success", false], + ["message", format ["Failed to store vehicle: %1", _result]] + ]], _player] call CFUNC(targetEvent); + }; + + private _garage = fromJSON _result; + GVAR(Registry) set [_uid, _garage]; + + [CRPC(garage,responseSyncGarage), [_garage], _player] call CFUNC(targetEvent); + [CRPC(garage,responseGarageAction), [createHashMapFromArray [ + ["action", "store"], + ["success", true], + ["message", "Vehicle stored in garage."] + ]], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + +[QGVAR(requestRetrieveVehicle), { + params [["_uid", "", [""]], ["_plate", "", [""]]]; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_uid isEqualTo "" || { _plate isEqualTo "" }) exitWith { + [CRPC(garage,responseGarageAction), [createHashMapFromArray [ + ["action", "retrieve"], + ["success", false], + ["message", "Select a stored vehicle to retrieve."] + ]], _player] call CFUNC(targetEvent); + }; + + private _payloadJson = toJSON (createHashMapFromArray [["plate", _plate]]); + ["garage:remove", [_uid, _payloadJson]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + [CRPC(garage,responseGarageAction), [createHashMapFromArray [ + ["action", "retrieve"], + ["success", false], + ["message", format ["Failed to retrieve vehicle: %1", _result]] + ]], _player] call CFUNC(targetEvent); + }; + + private _garage = fromJSON _result; + GVAR(Registry) set [_uid, _garage]; + + [CRPC(garage,responseSyncGarage), [_garage], _player] call CFUNC(targetEvent); + [CRPC(garage,responseGarageAction), [createHashMapFromArray [ + ["action", "retrieve"], + ["success", true], + ["message", "Vehicle retrieved from garage."] + ]], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestInitVG), { - params [["_uid", "", [""]], ["_vGarage", createHashMap, [createHashMap]]]; + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID!" }; - if (_vGarage isEqualTo createHashMap) exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid VGarage data!" }; - - GVAR(VGarageStore) call ["init", [_uid, _vGarage]]; + GVAR(VGarageStore) call ["init", [_uid]]; }] call CFUNC(addEventHandler); [QGVAR(requestGetVG), { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + params [["_uid", "", [""]], ["_field", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid Session!" }; + private _finalData = GVAR(VGarageStore) call ["get", [GVAR(VGRegistry), "owned:garage:fetch", _uid, _field]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(VGarageStore) call ["get", [_uid, _sync]]; + [CRPC(garage,responseSyncVG), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestSetVG), { @@ -91,10 +166,10 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "" || _key isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID or Key!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid Session!" }; + private _hashMap = GVAR(VGarageStore) call ["set", [GVAR(VGRegistry), "", _uid, _key, _value, _sync]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(VGarageStore) call ["set", [_uid, _key, _value, _sync]]; + [CRPC(garage,responseSyncVG), [_hashMap], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestMSetVG), { @@ -103,27 +178,26 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID!" }; if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid field pairs!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid Session!" }; + private _hashMap = GVAR(VGarageStore) call ["mset", [GVAR(VGRegistry), "", _uid, _fieldValuePairs, _sync]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(VGarageStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; + [CRPC(garage,responseSyncVG), [_hashMap], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestSaveVG), { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid Session!" }; + private _finalData = GVAR(VGarageStore) call ["save", [GVAR(VGRegistry), "", _uid]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(VGarageStore) call ["save", [_uid, _sync]]; + [CRPC(garage,responseSyncVG), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestRemoveVG), { params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VGarage] Empty/Invalid UID!" }; - - GVAR(VGarageStore) call ["remove", [_uid]]; + GVAR(VGarageStore) call ["remove", [GVAR(VGRegistry), _uid]]; }] call CFUNC(addEventHandler); diff --git a/arma/server/addons/garage/XEH_preStart.sqf b/arma/server/addons/garage/XEH_preStart.sqf index 0228885..a51262a 100644 --- a/arma/server/addons/garage/XEH_preStart.sqf +++ b/arma/server/addons/garage/XEH_preStart.sqf @@ -1,3 +1,2 @@ #include "script_component.hpp" - #include "XEH_PREP.hpp" diff --git a/arma/server/addons/garage/functions/fnc_initGarage.sqf b/arma/server/addons/garage/functions/fnc_initGarage.sqf index 306bddb..ae7897a 100644 --- a/arma/server/addons/garage/functions/fnc_initGarage.sqf +++ b/arma/server/addons/garage/functions/fnc_initGarage.sqf @@ -4,36 +4,30 @@ * File: fnc_initGarage.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2025-12-19 + * Last Update: 2026-02-05 * Public: No * * Description: - * No description added yet. + * Initializes all editor-placed garages. * - * Parameter(s): - * N/A + * Arguments: + * None * - * Returns: - * Something [BOOL] + * Return Value: + * None * - * Example(s): - * [parameter] call forge_x_component_fnc_myFunction + * Example: + * call forge_server_garage_fnc_initGarage */ -private _mC = "FORGE_CfgGarages"; -private _garages = "true" configClasses (missionConfigFile >> "FORGE_CfgGarages" >> "garages"); +private _garages = (allVariables missionNamespace) select { + private _var = missionNamespace getVariable _x; + ("garage" in _x) && { _var isEqualType objNull } && { !isNull _var } +}; + +if (_garages isEqualTo []) exitWith { ["INFO", "No editor-placed garages found."] call EFUNC(common,log) }; { - private _configName = configName(_x); - private _className = (missionConfigFile >> _mC >> "garages" >> _configName >> "className") call BFUNC(getCfgData); - private _pos = (missionConfigFile >> _mC >> "garages" >> _configName >> "pos") call BFUNC(getCfgData); - private _dir = (missionConfigFile >> _mC >> "garages" >> _configName >> "dir") call BFUNC(getCfgData); - private _garage = createSimpleObject [_className, [0, 0, 0]]; - - _garage setPosATL _pos; - _garage setDir _dir; - _garage allowDamage false; - _garage setVariable ["isGarage", true, true]; - - diag_log format ["[FORGE:Server:Garage] ClassName: %1 Pos: %2 Dir: %3", _className, _pos, _dir]; + private _garage = missionNamespace getVariable _x; + SETPVAR(_garage,isGarage,true); } forEach _garages; diff --git a/arma/server/addons/garage/functions/fnc_initGarageStore.sqf b/arma/server/addons/garage/functions/fnc_initGarageStore.sqf index 222463f..b714040 100644 --- a/arma/server/addons/garage/functions/fnc_initGarageStore.sqf +++ b/arma/server/addons/garage/functions/fnc_initGarageStore.sqf @@ -4,106 +4,74 @@ * File: fnc_initGarageStore.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-01-03 + * Last Update: 2026-02-13 * Public: No * * Description: - * Initializes the Garage store for managing player vehicles. - * Provides methods for syncing, saving, and applying vehicles to the player's garage. + * Initializes the Garage store for managing player vehicles. + * Provides methods for syncing, saving, and applying vehicles to the player's garage. * - * Parameter(s): - * N/A + * Arguments: + * None * - * Returns: - * Something [BOOL] + * Return Value: + * Garage store object [HASHMAP OBJECT] * - * Example(s): - * [parameter] call forge_x_component_fnc_myFunction + * Example: + * call forge_server_garage_fnc_initGarageStore */ #pragma hemtt ignore_variables ["_self"] -GVAR(GarageStore) = createHashMapObject [[ +GVAR(GarageBaseStore) = compileFinal createHashMapFromArray [ ["#base", EGVAR(common,BaseStore)], - ["#type", "IGarageStore"], - ["#create", { - GVAR(GarageRegistry) = createHashMap; - - _self set ["_registry", GVAR(GarageRegistry)]; - _self set ["_extCallPrefix", "garage"]; - _self set ["_readMethod", "get"]; - _self set ["_storeName", "Garage"]; - _self set ["_syncEventName", CRPC(garage,responseSyncGarage)]; - - ["INFO", "Garage Store Initialized!", nil, nil] call EFUNC(common,log); + ["#type", "GarageBaseStore"], + ["#create", compileFinal { + GVAR(Registry) = createHashMap; + ["INFO", "Garage Store Initialized!"] call EFUNC(common,log); }], - ["init", { - params [["_uid", "", [""]], ["_defaultGarage", createHashMap, [createHashMap]]]; + ["init", compileFinal { + params [["_uid", "", [""]]]; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _cached = GVAR(Registry) getOrDefault [_uid, nil]; + if !(isNil { _cached }) exitWith { [CRPC(garage,responseInitGarage), [_cached], _player] call CFUNC(targetEvent); _cached }; + + ["garage:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", format ["Failed to check if garage %1 exists! Using fallback garage.", _uid]] call EFUNC(common,log); + + private _fallbackGarage = createHashMap; + GVAR(Registry) set [_uid, _fallbackGarage]; + [CRPC(garage,responseInitGarage), [_fallbackGarage], _player] call CFUNC(targetEvent); + + _fallbackGarage + }; private _finalGarage = createHashMap; - ["garage:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - private _exists = _result == "true"; - - if !(_exists) then { - _finalGarage = _defaultGarage; - - ["garage:create", [_uid]] call EFUNC(extension,extCall); - ["INFO", format ["Created new garage for %1", _uid], nil, nil] call EFUNC(common,log); + if (_result == "true") then { + _finalGarage = _self call ["fetch", ["garage:get", _uid]]; + ["INFO", format ["Found garage for %1", _uid]] call EFUNC(common,log); } else { - _finalGarage = _self call ["fetch", [_uid]]; - ["INFO", format ["Found garage for %1", _uid], nil, nil] call EFUNC(common,log); + ["garage:create", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", format ["Failed to create garage for %1! Using fallback garage.", _uid]] call EFUNC(common,log); + + GVAR(Registry) set [_uid, _finalGarage]; + [CRPC(garage,responseInitGarage), [_finalGarage], _player] call CFUNC(targetEvent); + + _finalGarage + }; + + ["INFO", format ["Created new garage for %1", _uid]] call EFUNC(common,log); }; - GVAR(GarageRegistry) set [_uid, _finalGarage]; - - private _player = [_uid] call EFUNC(common,getPlayer); + GVAR(Registry) set [_uid, _finalGarage]; [CRPC(garage,responseInitGarage), [_finalGarage], _player] call CFUNC(targetEvent); _finalGarage - }], - ["set", { - params [["_uid", "", [""]], ["_field", "", [""]], ["_value", nil], ["_sync", false, [false]]]; - - private _existingData = GVAR(GarageRegistry) get _uid; - private _finalData = +_existingData; - private _hashMap = createHashMap; - - _finalData set [_field, _value]; - _hashMap set [_field, _value]; - GVAR(GarageRegistry) set [_uid, _finalData]; - - if (_sync) then { - private _json = _self call ["toJSON", [_finalData]]; - ["garage:update", [_uid, _json]] call EFUNC(extension,extCall); - }; - - private _player = [_uid] call EFUNC(common,getPlayer); - [CRPC(garage,responseSyncGarage), [_hashMap], _player] call CFUNC(targetEvent); - - _hashMap - }], - ["mset", { - params [["_uid", "", [""]], ["_fieldValuePairs", createHashMap, [createHashMap]], ["_sync", false, [false]]]; - - private _existingData = GVAR(GarageRegistry) get _uid; - private _finalData = +_existingData; - private _hashMap = createHashMap; - - { _finalData set [_x, _y]; } forEach _fieldValuePairs; - { _hashMap set [_x, _y]; } forEach _fieldValuePairs; - GVAR(GarageRegistry) set [_uid, _finalData]; - - if (_sync) then { - private _json = _self call ["toJSON", [_finalData]]; - ["garage:update", [_uid, _json]] call EFUNC(extension,extCall); - }; - - private _player = [_uid] call EFUNC(common,getPlayer); - [CRPC(garage,responseSyncGarage), [_hashMap], _player] call CFUNC(targetEvent); - - _hashMap }] -]]; +]; -SETMVAR(FORGE_GarageStore,GVAR(GarageStore)); +GVAR(GarageStore) = createHashMapObject [GVAR(GarageBaseStore)]; GVAR(GarageStore) diff --git a/arma/server/addons/garage/functions/fnc_initVGStore.sqf b/arma/server/addons/garage/functions/fnc_initVGStore.sqf index 5fb13d4..70e7d5b 100644 --- a/arma/server/addons/garage/functions/fnc_initVGStore.sqf +++ b/arma/server/addons/garage/functions/fnc_initVGStore.sqf @@ -4,68 +4,148 @@ * File: fnc_initVGStore.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-01-03 + * Last Update: 2026-02-13 * Public: No * * Description: - * Initializes the Virtual Garage store for managing player vehicle unlocks. - * Provides methods for syncing, saving, and applying virtual vehicles to BIS Garage. + * Initializes the Virtual Garage store for managing player vehicle unlocks. + * Provides methods for syncing, saving, and applying virtual vehicles to BIS Garage. * - * Parameter(s): - * N/A + * Arguments: + * None * - * Returns: - * Something [BOOL] + * Return Value: + * VG store object [HASHMAP OBJECT] * - * Example(s): - * [parameter] call forge_x_component_fnc_myFunction + * Example: + * call forge_server_garage_fnc_initVGStore */ #pragma hemtt ignore_variables ["_self"] -GVAR(VGarageStore) = createHashMapObject [[ - ["#base", EGVAR(common,BaseStore)], - ["#type", "IVGarageStore"], - ["#create", { - GVAR(VGarageRegistry) = createHashMap; +GVAR(VGarageModel) = compileFinal createHashMapObject [[ + ["#type", "VGarageModel"], + ["defaults", compileFinal { + private _vGarage = createHashMap; - _self set ["_registry", GVAR(VGarageRegistry)]; - _self set ["_extCallPrefix", "owned:garage"]; - _self set ["_readMethod", "fetch"]; - _self set ["_storeName", "VGarage"]; - _self set ["_syncEventName", CRPC(garage,responseSyncVG)]; + _vGarage set ["armor", []]; + _vGarage set ["cars", ["B_Quadbike_01_F"]]; + _vGarage set ["helis", []]; + _vGarage set ["naval", []]; + _vGarage set ["other", []]; + _vGarage set ["planes", []]; - ["INFO", "VGarage Store Initialized!", nil, nil] call EFUNC(common,log); - }], - ["init", { - params [["_uid", "", [""]], ["_defaultVGarage", createHashMap, [createHashMap]]]; - - private _finalVGarage = createHashMap; - - ["owned:garage:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - private _exists = _result == "true"; - - if !(_exists) then { - _finalVGarage = _defaultVGarage; - - ["owned:garage:create", [_uid]] call EFUNC(extension,extCall); - ["INFO", format ["Created new VGarage for %1", _uid], nil, nil] call EFUNC(common,log); - } else { - private _existingVGarage = _self call ["fetch", [_uid]]; - _finalVGarage = _existingVGarage; - - { - if !(_x in _finalVGarage) then { _finalVGarage set [_x, _y]; }; - } forEach _defaultVGarage; - }; - - GVAR(VGarageRegistry) set [_uid, _finalVGarage]; - - private _player = [_uid] call EFUNC(common,getPlayer); - [CRPC(garage,responseInitVG), [_finalVGarage], _player] call CFUNC(targetEvent); - - _finalVGarage + _vGarage }] ]]; -SETMVAR(FORGE_VGarageStore,GVAR(VGarageStore)); +GVAR(VGBaseStore) = compileFinal createHashMapFromArray [ + ["#base", EGVAR(common,BaseStore)], + ["#type", "VGBaseStore"], + ["#create", compileFinal { + GVAR(VGRegistry) = createHashMap; + ["INFO", "VGarage Store Initialized!"] call EFUNC(common,log); + }], + ["init", compileFinal { + params [["_uid", "", [""]]]; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _cached = GVAR(VGRegistry) getOrDefault [_uid, nil]; + if !(isNil { _cached }) exitWith { + [CRPC(garage,responseInitVG), [_cached], _player] call CFUNC(targetEvent); + _cached + }; + + ["owned:garage:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", format ["Failed to check if virtual garage %1 exists! Using fallback virtual garage.", _uid]] call EFUNC(common,log); + + private _fallbackVGarage = GVAR(VGarageModel) call ["defaults", []]; + GVAR(VGRegistry) set [_uid, _fallbackVGarage]; + [CRPC(garage,responseInitVG), [_fallbackVGarage], _player] call CFUNC(targetEvent); + + _fallbackVGarage + }; + + private _finalVGarage = createHashMap; + + if (_result == "true") then { + _finalVGarage = _self call ["fetch", ["owned:garage:fetch", _uid]]; + ["INFO", format ["Found virtual garage for %1", _uid]] call EFUNC(common,log); + } else { + _finalVGarage = GVAR(VGarageModel) call ["defaults", []]; + + ["owned:garage:create", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", format ["Failed to create virtual garage for %1! Using fallback virtual garage.", _uid]] call EFUNC(common,log); + + GVAR(VGRegistry) set [_uid, _finalVGarage]; + [CRPC(garage,responseInitVG), [_finalVGarage], _player] call CFUNC(targetEvent); + + _finalVGarage + }; + + ["INFO", format ["Created new virtual garage for %1", _uid]] call EFUNC(common,log); + }; + + GVAR(VGRegistry) set [_uid, _finalVGarage]; + [CRPC(garage,responseInitVG), [_finalVGarage], _player] call CFUNC(targetEvent); + + _finalVGarage + }], + ["grantVehicles", compileFinal { + params [["_uid", "", [""]], ["_vehicles", [], [[]]], ["_commit", false, [false]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", "Virtual garage grant failed."], + ["patch", createHashMap], + ["granted", []], + ["garage", createHashMap] + ]; + + private _defaultGarage = GVAR(VGarageModel) call ["defaults", []]; + private _garage = +(GVAR(VGRegistry) getOrDefault [_uid, _defaultGarage]); + private _patch = createHashMap; + private _granted = []; + private _categoriesToSync = []; + + { + private _className = _x getOrDefault ["classname", ""]; + private _category = toLowerANSI (_x getOrDefault ["category", ""]); + + if (_className isEqualTo "") exitWith { + _result set ["message", "Vehicle checkout entry was missing a classname."]; + }; + + if !(_category in ["cars", "armor", "helis", "planes", "naval", "other"]) exitWith { + _result set ["message", format ["Vehicle category '%1' is unsupported.", _category]]; + }; + + private _categoryUnlocks = +(_garage getOrDefault [_category, []]); + _categoryUnlocks pushBackUnique _className; + _garage set [_category, _categoryUnlocks]; + _categoriesToSync pushBackUnique _category; + _granted pushBack (createHashMapFromArray [ + ["classname", _className], + ["category", _category] + ]); + } forEach _vehicles; + + { + private _category = _x; + _patch set [_category, _garage getOrDefault [_category, []]]; + } forEach _categoriesToSync; + + if (_commit) then { GVAR(VGRegistry) set [_uid, _garage]; }; + + _result set ["success", true]; + _result set ["message", ""]; + _result set ["patch", _patch]; + _result set ["granted", _granted]; + _result set ["garage", _garage]; + _result + }] +]; + +GVAR(VGarageStore) = createHashMapObject [GVAR(VGBaseStore)]; GVAR(VGarageStore) diff --git a/arma/server/addons/locker/README.md b/arma/server/addons/locker/README.md index e23c4cb..b9c2ce7 100644 --- a/arma/server/addons/locker/README.md +++ b/arma/server/addons/locker/README.md @@ -1,4 +1,3 @@ -forge_server_locker -=================== +# forge_server_locker Description for this addon diff --git a/arma/server/addons/locker/XEH_postInit.sqf b/arma/server/addons/locker/XEH_postInit.sqf index 9571a80..4125916 100644 --- a/arma/server/addons/locker/XEH_postInit.sqf +++ b/arma/server/addons/locker/XEH_postInit.sqf @@ -1,3 +1,3 @@ #include "script_component.hpp" -[] call FUNC(initLocker); +call FUNC(initLocker); diff --git a/arma/server/addons/locker/XEH_preInit.sqf b/arma/server/addons/locker/XEH_preInit.sqf index b4dc94a..a1b5d92 100644 --- a/arma/server/addons/locker/XEH_preInit.sqf +++ b/arma/server/addons/locker/XEH_preInit.sqf @@ -7,32 +7,32 @@ PREP_RECOMPILE_END; // private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)]; [QGVAR(requestInitLocker), { - params [["_uid", "", [""]], ["_locker", createHashMap, [createHashMap]]]; + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID!" }; - GVAR(LockerStore) call ["init", [_uid, _locker]]; + GVAR(LockerStore) call ["init", [_uid]]; }] call CFUNC(addEventHandler); [QGVAR(requestGetLocker), { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + params [["_uid", "", [""]], ["_field", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid Session!" }; + private _finalData = GVAR(LockerStore) call ["get", [GVAR(Registry), "locker:get", _uid, _field]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(LockerStore) call ["get", [_uid, _sync]]; + [CRPC(locker,responseSyncLocker), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestSetLocker), { - params [["_uid", "", [""]], ["_key", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; + params [["_uid", "", [""]], ["_field", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; - if (_uid isEqualTo "" || _key isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID or Key!" }; + if (_uid isEqualTo "" || _field isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID or Field!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid Session!" }; + private _hashMap = GVAR(LockerStore) call ["set", [GVAR(Registry), "locker:update", _uid, _field, _value, _sync]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(LockerStore) call ["set", [_uid, _key, _value, _sync]]; + [CRPC(locker,responseSyncLocker), [_hashMap], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestMSetLocker), { @@ -41,60 +41,67 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID!" }; if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid field pairs!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid Session!" }; + private _hashMap = GVAR(LockerStore) call ["mset", [GVAR(Registry), "locker:update", _uid, _fieldValuePairs, _sync]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(LockerStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; + [CRPC(locker,responseSyncLocker), [_hashMap], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestSaveLocker), { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid Session!" }; + private _finalData = GVAR(LockerStore) call ["save", [GVAR(Registry), "locker:update", _uid]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(LockerStore) call ["save", [_uid, _sync]]; + [CRPC(locker,responseSyncLocker), [_finalData], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + +[QGVAR(requestOverrideLocker), { + params [["_uid", "", [""]], ["_data", createHashMap, [createHashMap]]]; + + if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID!" }; + GVAR(Registry) set [_uid, _data]; + + private _player = [_uid] call EFUNC(common,getPlayer); + [CRPC(locker,responseSyncLocker), [_data], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestRemoveLocker), { params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Locker] Empty/Invalid UID!" }; - GVAR(LockerStore) call ["remove", [_uid]]; }] call CFUNC(addEventHandler); [QGVAR(requestInitVA), { - params [["_uid", "", [""]], ["_vArsenal", createHashMap, [createHashMap]]]; + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID!" }; - if (_vArsenal isEqualTo createHashMap) exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid VArsenal data!" }; - - GVAR(VArsenalStore) call ["init", [_uid, _vArsenal]]; + GVAR(VAStore) call ["init", [_uid]]; }] call CFUNC(addEventHandler); [QGVAR(requestGetVA), { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + params [["_uid", "", [""]], ["_field", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid Session!" }; + private _finalData = GVAR(VAStore) call ["get", [GVAR(VARegistry), "owned:locker:fetch", _uid, _field]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(VArsenalStore) call ["get", [_uid, _sync]]; + [CRPC(locker,responseSyncVA), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestSetVA), { - params [["_uid", "", [""]], ["_key", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; + params [["_uid", "", [""]], ["_field", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; - if (_uid isEqualTo "" || _key isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID or Key!" }; + if (_uid isEqualTo "" || _field isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID or Field!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid Session!" }; + private _hashMap = GVAR(VAStore) call ["set", [GVAR(VARegistry), "owned:locker:update", _uid, _field, _value, _sync]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(VArsenalStore) call ["set", [_uid, _key, _value, _sync]]; + [CRPC(locker,responseSyncVA), [_hashMap], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestMSetVA), { @@ -103,27 +110,26 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID!" }; if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid field pairs!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid Session!" }; + private _hashMap = GVAR(VAStore) call ["mset", [GVAR(VARegistry), "owned:locker:update", _uid, _fieldValuePairs, _sync]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(VArsenalStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; + [CRPC(locker,responseSyncVA), [_hashMap], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestSaveVA), { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid Session!" }; + private _finalData = GVAR(VAStore) call ["save", [GVAR(VARegistry), "owned:locker:update", _uid]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(VArsenalStore) call ["save", [_uid, _sync]]; + [CRPC(locker,responseSyncVA), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestRemoveVA), { params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:VArsenal] Empty/Invalid UID!" }; - - GVAR(VArsenalStore) call ["remove", [_uid]]; + GVAR(VAStore) call ["remove", [GVAR(VARegistry), _uid]]; }] call CFUNC(addEventHandler); diff --git a/arma/server/addons/locker/XEH_preStart.sqf b/arma/server/addons/locker/XEH_preStart.sqf index 0228885..a51262a 100644 --- a/arma/server/addons/locker/XEH_preStart.sqf +++ b/arma/server/addons/locker/XEH_preStart.sqf @@ -1,3 +1,2 @@ #include "script_component.hpp" - #include "XEH_PREP.hpp" diff --git a/arma/server/addons/locker/functions/fnc_initLocker.sqf b/arma/server/addons/locker/functions/fnc_initLocker.sqf index 7038d44..248e91b 100644 --- a/arma/server/addons/locker/functions/fnc_initLocker.sqf +++ b/arma/server/addons/locker/functions/fnc_initLocker.sqf @@ -4,36 +4,31 @@ * File: fnc_initLocker.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2025-12-19 + * Last Update: 2026-02-05 * Public: No * * Description: - * No description added yet. + * Initializes lockers by hiding editor-placed global locker objects. + * Each client will create their own local instance. * - * Parameter(s): - * N/A + * Arguments: + * None * - * Returns: - * Something [BOOL] + * Return Value: + * None * - * Example(s): - * [parameter] call forge_x_component_fnc_myFunction + * Example: + * call forge_server_locker_fnc_initLocker */ -private _mC = "FORGE_CfgLockers"; -private _lockers = "true" configClasses (missionConfigFile >> "FORGE_CfgLockers" >> "lockers"); +private _lockers = (allVariables missionNamespace) select { + private _var = missionNamespace getVariable _x; + ("locker" in _x) && { _var isEqualType objNull } && { !isNull _var } && { _x isNotEqualTo "forge_locker_box" } +}; + +if (_lockers isEqualTo []) exitWith { ["INFO", "No editor-placed lockers found."] call EFUNC(common,log) }; { - private _configName = configName(_x); - private _className = (missionConfigFile >> _mC >> "lockers" >> _configName >> "className") call BFUNC(getCfgData); - private _pos = (missionConfigFile >> _mC >> "lockers" >> _configName >> "pos") call BFUNC(getCfgData); - private _dir = (missionConfigFile >> _mC >> "lockers" >> _configName >> "dir") call BFUNC(getCfgData); - private _locker = createSimpleObject [_className, [0, 0, 0]]; - - _locker setPosATL _pos; - _locker setDir _dir; - _locker allowDamage false; - _locker setVariable ["isLocker", true, true]; - - diag_log format ["[FORGE:Server:Locker] ClassName: %1 Pos: %2 Dir: %3", _className, _pos, _dir]; + private _locker = missionNamespace getVariable _x; + _locker hideObjectGlobal true; } forEach _lockers; diff --git a/arma/server/addons/locker/functions/fnc_initLockerStore.sqf b/arma/server/addons/locker/functions/fnc_initLockerStore.sqf index 66f29d3..352c940 100644 --- a/arma/server/addons/locker/functions/fnc_initLockerStore.sqf +++ b/arma/server/addons/locker/functions/fnc_initLockerStore.sqf @@ -4,108 +4,136 @@ * File: fnc_initLockerStore.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-01-03 + * Last Update: 2026-02-13 * Public: No * * Description: - * Initializes the Locker store for managing player locker items. - * Provides methods for syncing, saving, and applying locker items to the player's locker. + * Initializes the Locker store for managing player locker items. + * Provides methods for syncing, saving, and applying locker items to the player's locker. * - * Parameter(s): - * N/A + * Arguments: + * None * - * Returns: - * vArsenal class object [HASHMAP OBJECT] + * Return Value: + * Locker store object [HASHMAP OBJECT] * - * Example(s): - * [parameter] call forge_client_locker_fnc_initVAStore; + * Example: + * call forge_server_locker_fnc_initLockerStore */ #pragma hemtt ignore_variables ["_self"] -GVAR(LockerStore) = createHashMapObject [[ +GVAR(LockerBaseStore) = compileFinal createHashMapFromArray [ ["#base", EGVAR(common,BaseStore)], - ["#type", "ILockerStore"], - ["#create", { - GVAR(LockerRegistry) = createHashMap; - - _self set ["_registry", GVAR(LockerRegistry)]; - _self set ["_extCallPrefix", "locker"]; - _self set ["_readMethod", "get"]; - _self set ["_storeName", "Locker"]; - _self set ["_syncEventName", CRPC(locker,responseSyncLocker)]; - - ["INFO", "Locker Store Initialized!", nil, nil] call EFUNC(common,log); + ["#type", "LockerBaseStore"], + ["#create", compileFinal { + GVAR(Registry) = createHashMap; + ["INFO", "Locker Store Initialized!"] call EFUNC(common,log); }], - ["init", { - params [["_uid", "", [""]], ["_defaultLocker", createHashMap, [createHashMap]]]; + ["init", compileFinal { + params [["_uid", "", [""]]]; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _cached = GVAR(Registry) getOrDefault [_uid, nil]; + if !(isNil { _cached }) exitWith { [CRPC(locker,responseInitLocker), [_cached], _player] call CFUNC(targetEvent); _cached }; + + ["locker:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", format ["Failed to check if locker %1 exists! Using fallback locker.", _uid]] call EFUNC(common,log); + + private _fallbackLocker = createHashMap; + GVAR(Registry) set [_uid, _fallbackLocker]; + [CRPC(locker,responseInitLocker), [_fallbackLocker], _player] call CFUNC(targetEvent); + + _fallbackLocker + }; private _finalLocker = createHashMap; - ["locker:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - private _exists = _result == "true"; - - if !(_exists) then { - _finalLocker = _defaultLocker; - - ["locker:create", [_uid]] call EFUNC(extension,extCall); - ["INFO", format ["Created new locker for %1", _uid], nil, nil] call EFUNC(common,log); + if (_result == "true") then { + _finalLocker = _self call ["fetch", ["locker:get", _uid]]; + ["INFO", format ["Found locker for %1", _uid]] call EFUNC(common,log); } else { - _finalLocker = _self call ["fetch", [_uid]]; - ["INFO", format ["Found locker for %1", _uid], nil, nil] call EFUNC(common,log); + ["locker:create", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", format ["Failed to create locker for %1! Using fallback locker.", _uid]] call EFUNC(common,log); + + GVAR(Registry) set [_uid, _finalLocker]; + [CRPC(locker,responseInitLocker), [_finalLocker], _player] call CFUNC(targetEvent); + + _finalLocker + }; + + ["INFO", format ["Created new locker for %1", _uid]] call EFUNC(common,log); }; - GVAR(LockerRegistry) set [_uid, _finalLocker]; - - private _player = [_uid] call EFUNC(common,getPlayer); - + GVAR(Registry) set [_uid, _finalLocker]; [CRPC(locker,responseInitLocker), [_finalLocker], _player] call CFUNC(targetEvent); _finalLocker }], - ["set", { - params [["_uid", "", [""]], ["_field", "", [""]], ["_value", nil], ["_sync", false, [false]]]; + ["grantItems", compileFinal { + params [["_uid", "", [""]], ["_items", [], [[]]], ["_commit", false, [false]]]; - private _existingData = GVAR(LockerRegistry) get _uid; - private _finalData = +_existingData; - private _hashMap = createHashMap; + private _result = createHashMapFromArray [ + ["success", false], + ["message", "Locker grant failed."], + ["patch", createHashMap], + ["granted", []], + ["locker", createHashMap] + ]; - _finalData set [_field, _value]; - _hashMap set [_field, _value]; - GVAR(LockerRegistry) set [_uid, _finalData]; + private _locker = +(GVAR(Registry) getOrDefault [_uid, createHashMap]); + private _patch = createHashMap; + private _granted = []; - if (_sync) then { - private _json = _self call ["toJSON", [_finalData]]; - ["locker:update", [_uid, _json]] call EFUNC(extension,extCall); + { + private _className = _x getOrDefault ["classname", ""]; + private _category = toLowerANSI (_x getOrDefault ["category", ""]); + private _quantity = floor ((_x getOrDefault ["quantity", 0]) max 0); + private _lockerCategory = switch (_category) do { + case "item": { "item" }; + case "weapon": { "weapon" }; + case "magazine": { "magazine" }; + case "backpack": { "backpack" }; + default { "" }; + }; + + if (_className isEqualTo "" || { _lockerCategory isEqualTo "" } || { _quantity <= 0 }) exitWith { + _result set ["message", "Checkout item was missing a valid classname, category, or quantity."]; + _result set ["success", false]; + }; + + private _entry = +(_locker getOrDefault [_className, createHashMap]); + private _amount = _entry getOrDefault ["amount", 0]; + private _updatedEntry = createHashMapFromArray [ + ["amount", (_amount + _quantity)], + ["classname", _className], + ["category", _lockerCategory] + ]; + + _locker set [_className, _updatedEntry]; + _patch set [_className, _updatedEntry]; + _granted pushBack (createHashMapFromArray [ + ["classname", _className], + ["category", _lockerCategory], + ["quantity", _quantity] + ]); + } forEach _items; + + if ((count (keys _locker)) > 25) exitWith { + _result set ["message", "Locker capacity would exceed 25 unique items. Clear space before checkout."]; + _result }; + if (_commit) then { GVAR(Registry) set [_uid, _locker]; }; - private _player = [_uid] call EFUNC(common,getPlayer); - [CRPC(locker,responseSyncLocker), [_hashMap], _player] call CFUNC(targetEvent); - - _hashMap - }], - ["mset", { - params [["_uid", "", [""]], ["_fieldValuePairs", createHashMap, [createHashMap]], ["_sync", false, [false]]]; - - private _existingData = GVAR(LockerRegistry) get _uid; - private _finalData = +_existingData; - private _hashMap = createHashMap; - - { _finalData set [_x, _y]; } forEach _fieldValuePairs; - { _hashMap set [_x, _y]; } forEach _fieldValuePairs; - - GVAR(LockerRegistry) set [_uid, _finalData]; - - if (_sync) then { - private _json = _self call ["toJSON", [_finalData]]; - ["locker:update", [_uid, _json]] call EFUNC(extension,extCall); - }; - - private _player = [_uid] call EFUNC(common,getPlayer); - [CRPC(locker,responseSyncLocker), [_hashMap], _player] call CFUNC(targetEvent); - - _hashMap + _result set ["success", true]; + _result set ["message", ""]; + _result set ["patch", _patch]; + _result set ["granted", _granted]; + _result set ["locker", _locker]; + _result }] -]]; +]; -SETMVAR(FORGE_LockerStore,GVAR(LockerStore)); +GVAR(LockerStore) = createHashMapObject [GVAR(LockerBaseStore)]; GVAR(LockerStore) diff --git a/arma/server/addons/locker/functions/fnc_initVAStore.sqf b/arma/server/addons/locker/functions/fnc_initVAStore.sqf index fd85e6b..809fa95 100644 --- a/arma/server/addons/locker/functions/fnc_initVAStore.sqf +++ b/arma/server/addons/locker/functions/fnc_initVAStore.sqf @@ -4,68 +4,140 @@ * File: fnc_initVAStore.sqf * Author: IDSolutions * Date: 2025-12-17 - * Last Update: 2026-01-03 + * Last Update: 2026-02-13 * Public: No * * Description: - * Initializes the Virtual Arsenal store for managing player arsenal unlocks. - * Provides methods for syncing, saving, and applying virtual items to BIS Arsenal. + * Initializes the Virtual Arsenal store for managing player arsenal unlocks. + * Provides methods for syncing, saving, and applying virtual items to BIS Arsenal. * - * Parameter(s): - * N/A + * Arguments: + * None * - * Returns: - * vArsenal class object [HASHMAP OBJECT] + * Return Value: + * VA store object [HASHMAP OBJECT] * - * Example(s): - * [parameter] call forge_client_locker_fnc_initVAStore; + * Example: + * call forge_server_locker_fnc_initVAStore */ #pragma hemtt ignore_variables ["_self"] -GVAR(VArsenalStore) = createHashMapObject [[ - ["#base", EGVAR(common,BaseStore)], - ["#type", "IVArsenalStore"], - ["#create", { - GVAR(VArsenalRegistry) = createHashMap; +GVAR(VArsenalModel) = compileFinal createHashMapObject [[ + ["#type", "VArsenalModel"], + ["defaults", compileFinal { + private _vArsenal = createHashMap; - _self set ["_registry", GVAR(VArsenalRegistry)]; - _self set ["_extCallPrefix", "owned:locker"]; - _self set ["_readMethod", "fetch"]; - _self set ["_storeName", "VArsenal"]; - _self set ["_syncEventName", CRPC(locker,responseSyncVA)]; + _vArsenal set ["backpacks", ["B_AssaultPack_rgr"]]; + _vArsenal set ["items", ["FirstAidKit", "G_Combat", "H_Cap_blk_ION", "H_HelmetB", "ItemCompass", "ItemGPS", "ItemMap", "ItemRadio", "ItemWatch", "U_IG_Guerrilla_6_1", "V_TacVest_oli"]]; + _vArsenal set ["magazines", ["16Rnd_9x21_Mag", "30Rnd_65x39_caseless_black_mag", "Chemlight_blue", "Chemlight_green", "Chemlight_red", "Chemlight_yellow", "HandGrenade", "SmokeShell", "SmokeShellBlue", "SmokeShellGreen", "SmokeShellOrange", "SmokeShellPurple", "SmokeShellRed", "SmokeShellYellow"]]; + _vArsenal set ["weapons", ["arifle_MX_F", "hgun_P07_F"]]; - ["INFO", "VArsenal Store Initialized!", nil, nil] call EFUNC(common,log); - }], - ["init", { - params [["_uid", "", [""]], ["_defaultVArsenal", createHashMap, [createHashMap]]]; - - private _finalVArsenal = createHashMap; - - ["owned:locker:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - private _exists = _result == "true"; - - if !(_exists) then { - _finalVArsenal = _defaultVArsenal; - - ["owned:locker:create", [_uid]] call EFUNC(extension,extCall); - ["INFO", format ["Created new VArsenal for %1", _uid], nil, nil] call EFUNC(common,log); - } else { - private _existingVArsenal = _self call ["fetch", [_uid]]; - _finalVArsenal = _existingVArsenal; - - { - if !(_x in _finalVArsenal) then { _finalVArsenal set [_x, _y]; }; - } forEach _defaultVArsenal; - }; - - GVAR(VArsenalRegistry) set [_uid, _finalVArsenal]; - - private _player = [_uid] call EFUNC(common,getPlayer); - [CRPC(locker,responseInitVA), [_finalVArsenal], _player] call CFUNC(targetEvent); - - _finalVArsenal + _vArsenal }] ]]; -SETMVAR(FORGE_VArsenalStore,GVAR(VArsenalStore)); -GVAR(VArsenalStore) +GVAR(VABaseStore) = compileFinal createHashMapFromArray [ + ["#base", EGVAR(common,BaseStore)], + ["#type", "VABaseStore"], + ["#create", compileFinal { + GVAR(VARegistry) = createHashMap; + ["INFO", "VArsenal Store Initialized!"] call EFUNC(common,log); + }], + ["init", compileFinal { + params [["_uid", "", [""]]]; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _cached = GVAR(VARegistry) getOrDefault [_uid, nil]; + if !(isNil { _cached }) exitWith { + [CRPC(locker,responseInitVA), [_cached], _player] call CFUNC(targetEvent); + _cached + }; + + ["owned:locker:exists", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", format ["Failed to check if virtual arsenal %1 exists! Using fallback virtual arsenal.", _uid]] call EFUNC(common,log); + + private _fallbackVArsenal = GVAR(VArsenalModel) call ["defaults", []]; + GVAR(VARegistry) set [_uid, _fallbackVArsenal]; + [CRPC(locker,responseInitVA), [_fallbackVArsenal], _player] call CFUNC(targetEvent); + + _fallbackVArsenal + }; + + private _finalVArsenal = createHashMap; + + if (_result == "true") then { + _finalVArsenal = _self call ["fetch", ["owned:locker:fetch", _uid]]; + ["INFO", format ["Found virtual arsenal for %1", _uid]] call EFUNC(common,log); + } else { + _finalVArsenal = GVAR(VArsenalModel) call ["defaults", []]; + + ["owned:locker:create", [_uid]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", format ["Failed to create virtual arsenal for %1! Using fallback virtual arsenal.", _uid]] call EFUNC(common,log); + + GVAR(VARegistry) set [_uid, _finalVArsenal]; + [CRPC(locker,responseInitVA), [_finalVArsenal], _player] call CFUNC(targetEvent); + + _finalVArsenal + }; + + ["INFO", format ["Created new virtual arsenal for %1", _uid]] call EFUNC(common,log); + }; + + GVAR(VARegistry) set [_uid, _finalVArsenal]; + [CRPC(locker,responseInitVA), [_finalVArsenal], _player] call CFUNC(targetEvent); + + _finalVArsenal + }], + ["unlockItems", compileFinal { + params [["_uid", "", [""]], ["_items", [], [[]]], ["_commit", false, [false]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", "VA unlock failed."], + ["patch", createHashMap], + ["arsenal", createHashMap] + ]; + + private _defaultArsenal = GVAR(VArsenalModel) call ["defaults", []]; + private _arsenal = +(GVAR(VARegistry) getOrDefault [_uid, _defaultArsenal]); + private _patch = createHashMap; + private _categoriesToSync = []; + + { + private _item = _x; + private _className = _item getOrDefault ["classname", ""]; + private _category = toLowerANSI (_item getOrDefault ["category", ""]); + private _arsenalCategory = switch (_category) do { + case "item": { "items" }; + case "weapon": { "weapons" }; + case "magazine": { "magazines" }; + case "backpack": { "backpacks" }; + default { "items" }; + }; + + private _categoryUnlocks = +(_arsenal getOrDefault [_arsenalCategory, []]); + _categoryUnlocks pushBackUnique _className; + _arsenal set [_arsenalCategory, _categoryUnlocks]; + _categoriesToSync pushBackUnique _arsenalCategory; + } forEach _items; + + { + private _category = _x; + private _categoryUnlocks = _arsenal getOrDefault [_category, []]; + _patch set [_category, _categoryUnlocks]; + } forEach _categoriesToSync; + + if (_commit) then { GVAR(VARegistry) set [_uid, _arsenal]; }; + + _result set ["success", true]; + _result set ["message", ""]; + _result set ["patch", _patch]; + _result set ["arsenal", _arsenal]; + _result + }] +]; + +GVAR(VAStore) = createHashMapObject [GVAR(VABaseStore)]; +GVAR(VAStore) diff --git a/arma/server/addons/main/README.md b/arma/server/addons/main/README.md index 4de302b..014bc81 100644 --- a/arma/server/addons/main/README.md +++ b/arma/server/addons/main/README.md @@ -1,4 +1,3 @@ -forge_server_main -=================== +# forge_server_main Main Addon for forge-server diff --git a/arma/server/addons/main/XEH_postInit.sqf b/arma/server/addons/main/XEH_postInit.sqf index 23ac945..1b295d1 100644 --- a/arma/server/addons/main/XEH_postInit.sqf +++ b/arma/server/addons/main/XEH_postInit.sqf @@ -1,3 +1,3 @@ #include "script_component.hpp" -[] call FUNC(initStores); +call FUNC(initStores); diff --git a/arma/server/addons/main/functions/fnc_initStores.sqf b/arma/server/addons/main/functions/fnc_initStores.sqf index 52cdb8d..9093c32 100644 --- a/arma/server/addons/main/functions/fnc_initStores.sqf +++ b/arma/server/addons/main/functions/fnc_initStores.sqf @@ -11,31 +11,34 @@ * None * * Example: - * [] call forge_server_main_fnc_initStores; + * call forge_server_main_fnc_initStores; * * Public: No */ // Base -if (isNil QEGVAR(common,BaseStore)) then { [] call EFUNC(common,initBaseStore); }; +if (isNil QEGVAR(common,BaseStore)) then { call EFUNC(common,baseStore); }; // Actor -if (isNil QEGVAR(actor,ActorStore)) then { [] call EFUNC(actor,initActorStore); }; +if (isNil QEGVAR(actor,ActorStore)) then { call EFUNC(actor,initActorStore); }; // Bank -if (isNil QEGVAR(bank,BankStore)) then { [] call EFUNC(bank,initBankStore); }; +if (isNil QEGVAR(bank,BankStore)) then { call EFUNC(bank,initBankStore); }; // Garage -if (isNil QEGVAR(garage,GarageStore)) then { [] call EFUNC(garage,initGarageStore); }; +if (isNil QEGVAR(garage,GarageStore)) then { call EFUNC(garage,initGarageStore); }; // VGarage -if (isNil QEGVAR(garage,VGarageStore)) then { [] call EFUNC(garage,initVGStore); }; +if (isNil QEGVAR(garage,VGarageStore)) then { call EFUNC(garage,initVGStore); }; // Locker -if (isNil QEGVAR(locker,LockerStore)) then { [] call EFUNC(locker,initLockerStore); }; +if (isNil QEGVAR(locker,LockerStore)) then { call EFUNC(locker,initLockerStore); }; // VArsenal -if (isNil QEGVAR(locker,VArsenalStore)) then { [] call EFUNC(locker,initVAStore); }; +if (isNil QEGVAR(locker,VAStore)) then { call EFUNC(locker,initVAStore); }; // Org -if (isNil QEGVAR(org,OrgStore)) then { [] call EFUNC(org,initOrgStore); }; +if (isNil QEGVAR(org,OrgStore)) then { call EFUNC(org,initOrgStore); }; + +// Store +if (isNil QEGVAR(store,StoreStore)) then { call EFUNC(store,initStoreStore); }; diff --git a/arma/server/addons/org/README.md b/arma/server/addons/org/README.md index d498671..c9c6ab7 100644 --- a/arma/server/addons/org/README.md +++ b/arma/server/addons/org/README.md @@ -1,4 +1,3 @@ -forge_server_org -=================== +# forge_server_org Description for this addon diff --git a/arma/server/addons/org/XEH_PREP.hpp b/arma/server/addons/org/XEH_PREP.hpp index c7b9330..dc78ebd 100644 --- a/arma/server/addons/org/XEH_PREP.hpp +++ b/arma/server/addons/org/XEH_PREP.hpp @@ -1 +1,3 @@ PREP(initOrgStore); +PREP(memberService); +PREP(treasuryService); diff --git a/arma/server/addons/org/XEH_preInit.sqf b/arma/server/addons/org/XEH_preInit.sqf index 31a34cf..41fab57 100644 --- a/arma/server/addons/org/XEH_preInit.sqf +++ b/arma/server/addons/org/XEH_preInit.sqf @@ -7,34 +7,54 @@ PREP_RECOMPILE_END; // private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)]; [QGVAR(requestInitOrg), { - params [["_uid", "", [""]], ["_org", createHashMap, [createHashMap]]]; + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID!" }; - if (_org isEqualTo createHashMap) exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid Org data!" }; - GVAR(OrgStore) call ["init", [_uid, _org]]; + GVAR(OrgStore) call ["init", [_uid]]; +}] call CFUNC(addEventHandler); + +[QGVAR(requestCreateOrg), { + params [["_uid", "", [""]], ["_orgName", "", [""]]]; + + if (_uid isEqualTo "" || { _orgName isEqualTo "" }) exitWith { + diag_log "[FORGE:Server:Org] Empty/Invalid UID or Organization Name!" + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _result = GVAR(OrgStore) call ["register", [_uid, _orgName]]; + + if (_result getOrDefault ["success", false]) then { + private _actorPatch = _result getOrDefault ["actorPatch", createHashMap]; + if (_actorPatch isNotEqualTo createHashMap) then { + [CRPC(actor,responseSyncActor), [_actorPatch], _player] call CFUNC(targetEvent); + }; + }; + + [CRPC(org,responseCreateOrg), [_result], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestGetOrg), { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + params [["_uid", "", [""]], ["_field", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid Session!" }; + private _index = GVAR(IndexRegistry) get _uid; + private _key = _index get "orgID"; + private _finalData = GVAR(OrgStore) call ["get", [GVAR(Registry), _key, _field]]; + private _player = [_uid] call EFUNC(common,getPlayer); - GVAR(OrgStore) call ["get", [_uid, _sync]]; + [CRPC(org,responseSyncOrg), [_finalData], _player] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestSetOrg), { - params [["_uid", "", [""]], ["_key", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; + params [["_uid", "", [""]], ["_field", "", [""]], ["_value", nil, [[], "", 0, false, createHashMap]], ["_sync", false, [false]]]; - if (_uid isEqualTo "" || _key isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID or Key!" }; + if (_uid isEqualTo "" || _field isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID or Field!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid Session!" }; - - GVAR(OrgStore) call ["set", [_uid, _key, _value, _sync]]; + private _index = GVAR(IndexRegistry) get _uid; + private _key = _index get "orgID"; + GVAR(OrgStore) call ["set", [GVAR(Registry), "org:update", _key, _field, _value, _sync]]; }] call CFUNC(addEventHandler); [QGVAR(requestMSetOrg), { @@ -43,21 +63,53 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID!" }; if ((_fieldValuePairs isEqualTo createHashMap) || !(_fieldValuePairs isEqualType createHashMap)) exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid field pairs!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid Session!" }; + private _index = GVAR(IndexRegistry) get _uid; + private _key = _index get "orgID"; - GVAR(OrgStore) call ["mset", [_uid, _fieldValuePairs, _sync]]; + GVAR(OrgStore) call ["mset", [GVAR(Registry), "org:update", _key, _fieldValuePairs, _sync]]; +}] call CFUNC(addEventHandler); + +[QGVAR(requestAssignCreditLine), { + params [ + ["_uid", "", [""]], + ["_memberUid", "", [""]], + ["_memberName", "", [""]], + ["_amount", 0, [0]] + ]; + + if (_uid isEqualTo "" || { _memberUid isEqualTo "" } || { _amount <= 0 }) exitWith { + diag_log "[FORGE:Server:Org] Invalid credit line request payload!" + }; + + private _requester = [_uid] call EFUNC(common,getPlayer); + if (_requester isEqualTo objNull) exitWith {}; + + private _result = GVAR(OrgStore) call ["assignCreditLine", [_uid, _memberUid, _memberName, _amount]]; + if (_result getOrDefault ["success", false]) then { + private _patch = _result getOrDefault ["patch", createHashMap]; + + { + private _memberPlayer = [_x] call EFUNC(common,getPlayer); + if (_memberPlayer isNotEqualTo objNull && { _patch isNotEqualTo createHashMap }) then { + [CRPC(org,responseSyncOrg), [_patch], _memberPlayer] call CFUNC(targetEvent); + }; + } forEach (_result getOrDefault ["memberUids", []]); + }; + + [CRPC(org,responseCreditLine), [createHashMapFromArray [ + ["success", _result getOrDefault ["success", false]], + ["message", _result getOrDefault ["message", "Unable to assign credit line."]] + ]], _requester] call CFUNC(targetEvent); }] call CFUNC(addEventHandler); [QGVAR(requestSaveOrg), { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID!" }; - private _session = EGVAR(actor,PlayerSessions) getOrDefault [_uid, nil]; - if (isNil "_session") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid Session!" }; - - GVAR(OrgStore) call ["save", [_uid, _sync]]; + private _index = GVAR(IndexRegistry) get _uid; + private _key = _index get "orgID"; + GVAR(OrgStore) call ["save", [GVAR(Registry), "org:update", _key]]; }] call CFUNC(addEventHandler); [QGVAR(requestRemoveOrg), { @@ -65,5 +117,87 @@ PREP_RECOMPILE_END; if (_uid isEqualTo "") exitWith { diag_log "[FORGE:Server:Org] Empty/Invalid UID!" }; - GVAR(OrgStore) call ["remove", [_uid]]; + private _index = GVAR(IndexRegistry) get _uid; + private _key = _index get "orgID"; + GVAR(OrgStore) call ["delete", [_key]]; +}] call CFUNC(addEventHandler); + +[QGVAR(requestLeaveOrg), { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { + diag_log "[FORGE:Server:Org] Empty/Invalid UID for leave request!" + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith {}; + + private _result = GVAR(OrgStore) call ["leave", [_uid]]; + if (_result getOrDefault ["success", false]) then { + private _actorPatch = _result getOrDefault ["actorPatch", createHashMap]; + if (_actorPatch isNotEqualTo createHashMap) then { + [CRPC(actor,responseSyncActor), [_actorPatch], _player] call CFUNC(targetEvent); + }; + + GVAR(OrgStore) call ["init", [_uid]]; + + private _notificationParams = _result getOrDefault ["notification", []]; + if (_notificationParams isEqualType [] && { count _notificationParams > 0 }) then { + [CRPC(notifications,recieveNotification), _notificationParams, _player] call CFUNC(targetEvent); + }; + }; + + [CRPC(org,responseLeaveOrg), [createHashMapFromArray [ + ["success", _result getOrDefault ["success", false]], + ["message", _result getOrDefault ["message", "Unable to leave the organization."]] + ]], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + +[QGVAR(requestDisbandOrg), { + params [["_uid", "", [""]]]; + + if (_uid isEqualTo "") exitWith { + diag_log "[FORGE:Server:Org] Empty/Invalid UID for disband request!" + }; + + private _requester = [_uid] call EFUNC(common,getPlayer); + if (_requester isEqualTo objNull) exitWith {}; + + private _result = GVAR(OrgStore) call ["disband", [_uid]]; + if !(_result getOrDefault ["success", false]) exitWith { + [CRPC(org,responseDisbandOrg), [createHashMapFromArray [ + ["success", false], + ["message", _result getOrDefault ["message", "Failed to disband organization."]], + ["requester", true] + ]], _requester] call CFUNC(targetEvent); + }; + + { + [_x, _result] call { + params [["_member", createHashMap, [createHashMap]], ["_disbandResult", createHashMap, [createHashMap]]]; + + private _memberUid = _member getOrDefault ["uid", ""]; + if (_memberUid isEqualTo "") exitWith {}; + + private _memberPlayer = [_memberUid] call EFUNC(common,getPlayer); + if (_memberPlayer isEqualTo objNull) exitWith {}; + + private _actorPatch = _member getOrDefault ["actorPatch", createHashMap]; + if (_actorPatch isNotEqualTo createHashMap) then { + [CRPC(actor,responseSyncActor), [_actorPatch], _memberPlayer] call CFUNC(targetEvent); + }; + + GVAR(OrgStore) call ["init", [_memberUid]]; + [CRPC(org,responseDisbandOrg), [createHashMapFromArray [ + ["success", true], + ["message", _member getOrDefault ["message", _disbandResult getOrDefault ["message", "Organization disbanded."]]], + ["requester", _member getOrDefault ["requester", false]] + ]], _memberPlayer] call CFUNC(targetEvent); + + private _notificationParams = _member getOrDefault ["notification", []]; + if (_notificationParams isEqualType [] && { count _notificationParams > 0 }) then { + [CRPC(notifications,recieveNotification), _notificationParams, _memberPlayer] call CFUNC(targetEvent); + }; + }; + } forEach (_result getOrDefault ["members", []]); }] call CFUNC(addEventHandler); diff --git a/arma/server/addons/org/functions/fnc_initOrgStore.sqf b/arma/server/addons/org/functions/fnc_initOrgStore.sqf index 6c178b4..7e73c65 100644 --- a/arma/server/addons/org/functions/fnc_initOrgStore.sqf +++ b/arma/server/addons/org/functions/fnc_initOrgStore.sqf @@ -1,256 +1,422 @@ #include "..\script_component.hpp" /* + * File: fnc_initOrgStore.sqf * Author: IDSolutions - * Initializes the org store. + * Date: 2026-02-13 + * Last Update: 2026-03-13 + * Public: Yes + * + * Description: + * Initializes the org store for managing player organizations. + * Provides methods for creating, fetching, and updating organizations. * * Arguments: * None * * Return Value: - * None + * Org store object [HASHMAP OBJECT] * * Examples: - * [] call forge_server_org_fnc_initOrgStore - * - * Public: Yes + * call forge_server_org_fnc_initOrgStore */ +if (isNil QGVAR(OrgMembershipService)) then { call FUNC(memberService); }; +if (isNil QGVAR(OrgTreasuryService)) then { call FUNC(treasuryService); }; + #pragma hemtt ignore_variables ["_self"] -GVAR(OrgStore) = createHashMapObject [[ - ["#type", "IOrgStore"], - ["#create", { - GVAR(IndexRegistry) = createHashMap; - GVAR(OrgRegistry) = createHashMap; +GVAR(OrgModel) = compileFinal createHashMapObject [[ + ["#type", "OrgModel"], + ["defaults", compileFinal { + private _org = createHashMap; - private _hashMap = createHashMap; - _hashMap set ["id", "default"]; - _hashMap set ["owner", "server"]; - _hashMap set ["name", "Forge Dynamics"]; - _hashMap set ["funds", 200000]; - _hashMap set ["reputation", 0]; + _org set ["id", ""]; + _org set ["owner", ""]; + _org set ["name", ""]; + _org set ["funds", 0]; + _org set ["reputation", 0]; + _org set ["credit_lines", createHashMap]; + _org set ["assets", createHashMap]; + _org set ["fleet", createHashMap]; + _org set ["members", createHashMap]; - private _json = _self call ["toJSON", [_hashMap]]; - ["org:create", ["default", _json]] call EFUNC(extension,extCall); - - ["INFO", "Org Store Initialized!", nil, nil] call EFUNC(common,log); + _org }], - ["init", { - params [["_uid", "", [""]], ["_defaultOrg", createHashMap, [createHashMap]]]; - - private _actor = EGVAR(actor,ActorRegistry) get _uid; - private _orgID = _actor get "organization"; - - private _assets = createHashMap; - private _organization = createHashMap; - private _members = createHashMap; - - ["org:exists", [_orgID]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - - private _exists = _result == "true"; - private _player = [_uid] call EFUNC(common,getPlayer); - - if !(_exists) exitWith { - _self call ["default", [_uid, (name _player)]]; - ["WARNING", format ["No existing org found for %1, using default org.", _uid], nil, nil] call EFUNC(common,log); - }; - - private _regEntry = createHashMapFromArray [["orgID", _orgID]]; - GVAR(IndexRegistry) set [_uid, _regEntry]; - - _organization = _self call ["fetch", [_uid]]; - _members = _self call ["fetchMembers", [_uid]]; - // _assets = _self call ["fetchAssets", [_uid]]; - - private _finalOrg = GVAR(OrgRegistry) getOrDefault [_orgID, _organization]; - _finalOrg set ["members", _members]; - _finalOrg set ["assets", _assets]; - - GVAR(OrgRegistry) set [_orgID, _finalOrg, true]; - - [CRPC(org,responseInitOrg), [_finalOrg], _player] call CFUNC(targetEvent); - - _finalOrg - }], - ["default", { - params [["_uid", "", [""]], ["_name", "", [""]]]; - - private _assets = createHashMap; - private _members = createHashMap; - private _organization = createHashMap; - private _member = createHashMapFromArray [["uid", _uid], ["name", _name]]; - - private _regEntry = createHashMapFromArray [["orgID", "default"]]; - GVAR(IndexRegistry) set [_uid, _regEntry]; - - _organization = _self call ["fetch", ["default"]]; - _members set [_uid, _member]; - - private _finalOrg = GVAR(OrgRegistry) getOrDefault ["default", _organization]; - _finalOrg set ["members", _members]; - _finalOrg set ["assets", _assets]; - - GVAR(OrgRegistry) set ["default", _finalOrg]; - - private _player = [_uid] call EFUNC(common,getPlayer); - [CRPC(org,responseInitOrg), [_finalOrg], _player] call CFUNC(targetEvent); - - _finalOrg - }], - ["fetch", { - params [["_uid", "", [""]]]; - - private _index = ""; - private _organization = createHashMap; - private _orgID = "default"; - - if (_uid isNotEqualTo "default") then { - _index = GVAR(IndexRegistry) get _uid; - _orgID = _index get "orgID"; - }; - - ["org:get", [_orgID]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - ["INFO", format ["Data: %1", _result], nil, nil] call EFUNC(common,log); - - if (count _result > 0) then { _organization = _self call ["toHashMap", [_result]]; }; - - _organization - }], - ["fetchAssets", { - params [["_uid", "", [""]]]; - - private _assets = createHashMap; - private _index = GVAR(IndexRegistry) get _uid; - private _orgID = _index get "orgID"; - - ["org:assets:get", [_orgID]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - ["INFO", format ["Assets: %1", _result], nil, nil] call EFUNC(common,log); - - if (count _result > 0) then { _assets = _self call ["toHashMap", [_result]]; }; - - _assets - }], - ["fetchMembers", { - params [["_uid", "", [""]]]; - - private _members = createHashMap; - private _index = GVAR(IndexRegistry) get _uid; - private _orgID = _index get "orgID"; - - ["org:members:get", [_orgID]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; - ["INFO", format ["Members: %1", _result], nil, nil] call EFUNC(common,log); - - private _raw_members = _self call ["toHashMap", [_result]]; + ["migrate", compileFinal { + params [["_org", createHashMap, [createHashMap]]]; + private _defaults = _self call ["defaults", []]; { - private _uid = _x get "uid"; - _members set [_uid, _x]; - } forEach _raw_members; + if !(_x in _org) then { _org set [_x, _y]; }; + } forEach _defaults; - _members + _org }], - ["get", { - params [["_uid", "", [""]], ["_sync", false, [false]]]; + ["validate", compileFinal { + params [["_org", createHashMap, [createHashMap]]]; - private _finalOrg = createHashMap; - private _index = GVAR(IndexRegistry) get _uid; - private _orgID = _index get "orgID"; + private _id = _org get "id"; + private _owner = _org get "owner"; + private _name = _org get "name"; + private _funds = _org get "funds"; + private _reputation = _org get "reputation"; + private _creditLines = _org getOrDefault ["credit_lines", createHashMap]; - if (_sync) then { - _finalOrg = _self call ["fetch", [_uid]]; - GVAR(OrgRegistry) set [_orgID, _finalOrg]; - } else { - _finalOrg = GVAR(OrgRegistry) getOrDefault [_orgID, createHashMap]; + [_id, _owner, _name, _funds, _reputation, _creditLines] try { + if (_id isEqualTo "" || !(_id isEqualType "")) then { throw "Invalid ID!"; }; + if (_owner isEqualTo "" || !(_owner isEqualType "")) then { throw "Invalid Owner!"; }; + if (_name isEqualTo "" || !(_name isEqualType "")) then { throw "Invalid Name!"; }; + if (_funds isEqualTo 0 || !(_funds isEqualType 0)) then { throw "Invalid Funds!"; }; + if (_reputation isEqualTo 0 || !(_reputation isEqualType 0)) then { throw "Invalid Reputation!"; }; + if !(_creditLines isEqualType createHashMap) then { throw "Invalid Credit Lines!"; }; + } catch { + ["ERROR", format ["Failed to validate org %1!", _exception]] call EFUNC(common,log); + false }; - _finalOrg - }], - ["set", { - params [["_uid", "", [""]], ["_field", "", [""]], ["_value", nil], ["_sync", false, [false]]]; - - private _index = GVAR(IndexRegistry) get _uid; - private _orgID = _index get "orgID"; - private _organization = GVAR(OrgRegistry) get _orgID; - private _finalOrg = +_organization; - private _hashMap = createHashMap; - - _finalOrg set [_field, _value]; - _hashMap set [_field, _value]; - - GVAR(OrgRegistry) set [_orgID, _finalOrg]; - - if (_sync) then { - private _json = _self call ["toJSON", [_hashMap]]; - ["org:update", [_orgID, _json]] call EFUNC(extension,extCall); - }; - - private _player = [_uid] call EFUNC(common,getPlayer); - [CRPC(org,responseSyncOrg), [_hashMap], _player] call CFUNC(targetEvent); - - _hashMap - }], - ["mset", { - params [["_uid", "", [""]], ["_fieldValuePairs", createHashMap, [createHashMap]], ["_sync", false, [false]]]; - - private _index = GVAR(IndexRegistry) get _uid; - private _orgID = _index get "orgID"; - private _organization = GVAR(OrgRegistry) get _orgID; - private _finalOrg = +_organization; - private _hashMap = createHashMap; - - { _finalOrg set [_x, _y]; } forEach _fieldValuePairs; - { _hashMap set [_x, _y]; } forEach _fieldValuePairs; - - GVAR(OrgRegistry) set [_orgID, _finalOrg]; - - if (_sync) then { - private _json = _self call ["toJSON", [_hashMap]]; - ["org:update", [_orgID, _json]] call EFUNC(extension,extCall); - }; - - private _player = [_uid] call EFUNC(common,getPlayer); - [CRPC(org,responseSyncOrg), [_hashMap], _player] call CFUNC(targetEvent); - - _hashMap - }], - ["save", { - params [["_uid", "", [""]], ["_sync", false, [false]]]; - - private _index = GVAR(IndexRegistry) get _uid; - private _orgID = _index get "orgID"; - private _organization = GVAR(OrgRegistry) get _orgID; - private _finalOrg = +_organization; - private _json = _self call ["toJSON", [_finalOrg]]; - - ["org:update", [_orgID, _json]] call EFUNC(extension,extCall); - - if (_sync) then { - private _player = [_uid] call EFUNC(common,getPlayer); - [CRPC(org,responseSyncOrg), [_finalOrg], _player] call CFUNC(targetEvent); - }; - - _finalOrg - }], - ["remove", { - params [["_uid", "", [""]]]; - - private _index = GVAR(IndexRegistry) get _uid; - private _orgID = _index get "orgID"; - GVAR(OrgRegistry) deleteAt _orgID; - }], - ["toHashMap", { - params [["_data", "", [""]]]; - - private _hashMap = fromJSON _data; - _hashMap - }], - ["toJSON", { - params [["_data", createHashMap, [createHashMap]]]; - - private _json = toJSON _data; - _json + true }] ]]; -SETMVAR(FORGE_OrgStore,GVAR(OrgStore)); +GVAR(OrgBaseStore) = compileFinal createHashMapFromArray [ + ["#base", EGVAR(common,BaseStore)], + ["#type", "OrgBaseStore"], + ["#create", compileFinal { + GVAR(IndexRegistry) = createHashMap; + GVAR(Registry) = createHashMap; + ["INFO", "Org Store Initialized!"] call EFUNC(common,log); + + ["org:exists", ["default"]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", "Failed to check for default org!"] call EFUNC(common,log); + + private _defaultOrg = createHashMapFromArray [ + ["id", "default"], + ["owner", "server"], + ["name", "Forge Dynamics"], + ["funds", 200000], + ["reputation", 0], + ["credit_lines", createHashMap], + ["assets", createHashMap], + ["fleet", createHashMap], + ["members", createHashMap] + ]; + GVAR(Registry) set ["default", _defaultOrg]; + + _defaultOrg + }; + + private _defaultOrg = createHashMap; + if (_result == "true") then { + _defaultOrg = _self call ["fetch", ["org:get", "default"]]; + } else { + _defaultOrg set ["id", "default"]; + _defaultOrg set ["owner", "server"]; + _defaultOrg set ["name", "Forge Dynamics"]; + _defaultOrg set ["funds", 200000]; + _defaultOrg set ["reputation", 0]; + _defaultOrg set ["credit_lines", createHashMap]; + + private _defaultJson = _self call ["toJSON", [_defaultOrg]]; + ["org:create", ["default", _defaultJson]] call EFUNC(extension,extCall); + }; + + GVAR(Registry) set ["default", _defaultOrg]; + }], + ["verifyMember", compileFinal { + GVAR(OrgMembershipService) call ["verifyMember", _this] + }], + ["addMember", compileFinal { + GVAR(OrgMembershipService) call ["addMember", _this] + }], + ["removeMember", compileFinal { + GVAR(OrgMembershipService) call ["removeMember", _this] + }], + ["delete", compileFinal { + params [["_orgID", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", ""] + ]; + + if (_orgID isEqualTo "" || { toLower _orgID isEqualTo "default" }) exitWith { + _result set ["message", "Invalid organization ID."]; + _result + }; + + ["org:delete", [_orgID]] call EFUNC(extension,extCall) params ["_deleteResult", "_deleteSuccess"]; + if (!_deleteSuccess || { _deleteResult isNotEqualTo "OK" }) exitWith { + _result set ["message", format ["Failed to delete organization: %1", _deleteResult]]; + _result + }; + + GVAR(Registry) deleteAt _orgID; + _result set ["success", true]; + _result + }], + ["restoreDefaultMembership", compileFinal { + GVAR(OrgMembershipService) call ["restoreDefaultMembership", _this] + }], + ["leave", compileFinal { + GVAR(OrgMembershipService) call ["leave", _this] + }], + ["disband", compileFinal { + GVAR(OrgMembershipService) call ["disband", _this] + }], + ["assignCreditLine", compileFinal { + GVAR(OrgTreasuryService) call ["assignCreditLine", _this] + }], + ["buildChargeResult", compileFinal { + GVAR(OrgTreasuryService) call ["buildChargeResult", _this] + }], + ["chargeCheckout", compileFinal { + GVAR(OrgTreasuryService) call ["chargeCheckout", _this] + }], + ["addFleetVehicles", compileFinal { + params [["_requesterUid", "", [""]], ["_vehicles", [], [[]]], ["_commit", false, [false]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", "Unable to update organization fleet."], + ["patch", createHashMap], + ["memberUids", []] + ]; + + if (_requesterUid isEqualTo "" || { _vehicles isEqualTo [] }) exitWith { + _result set ["success", true]; + _result set ["message", ""]; + _result + }; + + private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; + private _orgID = _requesterActor getOrDefault ["organization", "default"]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _org = GVAR(Registry) getOrDefault [_orgID, createHashMap]; + if (_org isEqualTo createHashMap) exitWith { + _result set ["message", "Organization data is unavailable for fleet updates."]; + _result + }; + + private _fleet = +(_org getOrDefault ["fleet", createHashMap]); + private _fleetIndex = count (keys _fleet); + + { + private _className = _x getOrDefault ["classname", ""]; + private _category = toLowerANSI (_x getOrDefault ["category", "other"]); + if (_className isEqualTo "") exitWith { + _result set ["message", "Vehicle fleet entry was missing a classname."]; + }; + + private _fleetKey = format ["%1_%2", _className, _fleetIndex]; + while { _fleetKey in (keys _fleet) } do { + _fleetIndex = _fleetIndex + 1; + _fleetKey = format ["%1_%2", _className, _fleetIndex]; + }; + + private _displayName = getText (configFile >> "CfgVehicles" >> _className >> "displayName"); + if (_displayName isEqualTo "") then { _displayName = _className; }; + + _fleet set [_fleetKey, createHashMapFromArray [ + ["classname", _className], + ["name", _displayName], + ["type", _category], + ["status", "Ready"], + ["damage", "0%"] + ]]; + + _fleetIndex = _fleetIndex + 1; + } forEach _vehicles; + + private _patch = createHashMapFromArray [["fleet", _fleet]]; + if (_commit) then { + _patch = _self call ["mset", [GVAR(Registry), "org:update", _orgID, _patch, false]]; + }; + + _result set ["success", true]; + _result set ["message", ""]; + _result set ["patch", _patch]; + _result set ["memberUids", GVAR(OrgTreasuryService) call ["resolveOrgMemberUids", [_org, _requesterUid]]]; + _result + }], + ["loadById", compileFinal { + params [["_orgID", "", [""]]]; + + if (_orgID isEqualTo "") exitWith { createHashMap }; + + private _cachedOrg = GVAR(Registry) getOrDefault [_orgID, createHashMap]; + if (_cachedOrg isNotEqualTo createHashMap) exitWith { _cachedOrg }; + + ["org:exists", [_orgID]] call EFUNC(extension,extCall) params ["_existsResult", "_existsSuccess"]; + if (!_existsSuccess || { _existsResult isNotEqualTo "true" }) exitWith { createHashMap }; + + private _org = _self call ["fetch", ["org:get", _orgID]]; + if (_org isEqualTo createHashMap) exitWith { _org }; + + _org = GVAR(OrgModel) call ["migrate", [_org]]; + + private _memberRows = _self call ["fetch", ["org:members:get", _orgID]]; + if !(_memberRows isEqualType []) then { + _memberRows = []; + }; + + private _memberMap = createHashMap; + { + private _memberUid = _x getOrDefault ["uid", ""]; + if (_memberUid isNotEqualTo "") then { + _memberMap set [_memberUid, _x]; + }; + } forEach _memberRows; + + _org set ["members", _memberMap]; + GVAR(Registry) set [_orgID, _org, true]; + _org + }], + ["register", compileFinal { + params [["_uid", "", [""]], ["_orgName", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", ""], + ["org", createHashMap], + ["actorPatch", createHashMap] + ]; + + if (_uid isEqualTo "" || { _orgName isEqualTo "" }) exitWith { + _result set ["message", "A valid player and organization name are required."]; + _result + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; + private _existingOrgID = _actor getOrDefault ["organization", ""]; + if (_existingOrgID isNotEqualTo "" && { toLower _existingOrgID isNotEqualTo "default" }) exitWith { + _result set ["message", "Player already belongs to an organization."]; + _result + }; + + private _orgID = _actor getOrDefault ["phone_number", ""]; + if (_orgID isEqualTo "") exitWith { + _result set ["message", "Player phone number was not available for organization registration."]; + _result + }; + + ["org:exists", [_orgID]] call EFUNC(extension,extCall) params ["_existsResult", "_existsSuccess"]; + if (!_existsSuccess) exitWith { + _result set ["message", "Unable to verify organization ID availability."]; + _result + }; + + if (_existsResult isEqualTo "true") exitWith { + _result set ["message", "An organization already exists for this phone number."]; + _result + }; + + private _org = createHashMapFromArray [ + ["id", _orgID], + ["owner", _uid], + ["name", _orgName], + ["funds", 0], + ["reputation", 0], + ["credit_lines", createHashMap], + ["assets", createHashMap], + ["fleet", createHashMap], + ["members", createHashMap] + ]; + + private _json = _self call ["toJSON", [_org]]; + ["org:create", [_orgID, _json]] call EFUNC(extension,extCall) params ["_createResult", "_createSuccess"]; + if (!_createSuccess) exitWith { + _result set ["message", format ["Failed to create organization: %1", _createResult]]; + _result + }; + + if (_createResult isNotEqualTo "") then { + _org = _self call ["toHashMap", [_createResult]]; + }; + + _org set ["members", createHashMap]; + _org = _self call ["verifyMember", [_org, _orgID, _uid, _player, _actor]]; + + if (toLower _existingOrgID isEqualTo "default") then { + private _defaultOrg = _self call ["removeMember", ["default", _uid]]; + if (_defaultOrg isEqualTo createHashMap) then { + ["WARNING", format ["Failed to remove %1 from default org members after creating org %2.", _uid, _orgID]] call EFUNC(common,log); + }; + }; + + private _actorPatch = EGVAR(actor,ActorStore) call ["set", [EGVAR(actor,Registry), "actor:update", _uid, "organization", _orgID, true]]; + GVAR(IndexRegistry) set [_uid, createHashMapFromArray [["orgID", _orgID]]]; + GVAR(Registry) set [_orgID, _org, true]; + + _result set ["success", true]; + _result set ["org", _org]; + _result set ["actorPatch", _actorPatch]; + _result + }], + ["init", compileFinal { + params [["_uid", "", [""]]]; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _actor = EGVAR(actor,Registry) get _uid; + private _orgID = _actor get "organization"; + if (_orgID isEqualTo "") then { + _orgID = "default"; + }; + + private _cachedOrg = GVAR(Registry) getOrDefault [_orgID, nil]; + if !(isNil { _cachedOrg }) exitWith { + private _cachedOwner = _cachedOrg getOrDefault ["owner", ""]; + if (_orgID isEqualTo "default" || { _cachedOwner isEqualTo _uid }) then { + _cachedOrg = _self call ["verifyMember", [_cachedOrg, _orgID, _uid, _player, _actor]]; + }; + GVAR(Registry) set [_orgID, _cachedOrg, true]; + [CRPC(org,responseInitOrg), [_cachedOrg], _player] call CFUNC(targetEvent); + + _cachedOrg + }; + + ["org:exists", [_orgID]] call EFUNC(extension,extCall) params ["_result", "_isSuccess"]; + if !(_isSuccess) exitWith { + ["ERROR", format ["Failed to check for org %1! Using fallback org.", _orgID]] call EFUNC(common,log); + + private _fallbackOrg = GVAR(Registry) getOrDefault ["default", createHashMap]; + GVAR(IndexRegistry) set [_uid, createHashMapFromArray [["orgID", _orgID]]]; + + if (_orgID isEqualTo "default") then { + _fallbackOrg = _self call ["verifyMember", [_fallbackOrg, _orgID, _uid, _player, _actor]]; + }; + + GVAR(Registry) set [_orgID, _fallbackOrg, true]; + [CRPC(org,responseInitOrg), [_fallbackOrg], _player] call CFUNC(targetEvent); + + _fallbackOrg + }; + + private _finalOrg = createHashMap; + if (_result == "true") then { + _finalOrg = _self call ["loadById", [_orgID]]; + ["INFO", format ["Found org for %1", _orgID]] call EFUNC(common,log); + } else { + ["WARNING", format ["No existing org found for %1, using default org.", _uid]] call EFUNC(common,log); + _finalOrg = GVAR(Registry) getOrDefault ["default", createHashMap]; + _orgID = "default"; + }; + + GVAR(IndexRegistry) set [_uid, createHashMapFromArray [["orgID", _orgID]]]; + + private _finalOwner = _finalOrg getOrDefault ["owner", ""]; + if (_orgID isEqualTo "default" || { _finalOwner isEqualTo _uid }) then { + _finalOrg = _self call ["verifyMember", [_finalOrg, _orgID, _uid, _player, _actor]]; + }; + + GVAR(Registry) set [_orgID, _finalOrg, true]; + [CRPC(org,responseInitOrg), [_finalOrg], _player] call CFUNC(targetEvent); + + _finalOrg + }] +]; + +GVAR(OrgStore) = createHashMapObject [GVAR(OrgBaseStore)]; GVAR(OrgStore) diff --git a/arma/server/addons/org/functions/fnc_memberService.sqf b/arma/server/addons/org/functions/fnc_memberService.sqf new file mode 100644 index 0000000..7e47105 --- /dev/null +++ b/arma/server/addons/org/functions/fnc_memberService.sqf @@ -0,0 +1,243 @@ +#include "..\script_component.hpp" + +#pragma hemtt ignore_variables ["_self"] +GVAR(OrgMembershipServiceBase) = compileFinal createHashMapFromArray [ + ["#type", "OrgMembershipService"], + ["buildMembershipResult", compileFinal { + params [["_message", "", [""]]]; + + createHashMapFromArray [ + ["success", false], + ["message", _message], + ["actorPatch", createHashMap] + ] + }], + ["verifyMember", compileFinal { + params [["_org", createHashMap, [createHashMap]], ["_orgID", "", [""]], ["_uid", "", [""]], ["_player", objNull, [objNull]], ["_actor", createHashMap, [createHashMap]]]; + + if (_orgID isEqualTo "" || { _uid isEqualTo "" }) exitWith { _org }; + + private _members = _org getOrDefault ["members", createHashMap]; + if ((_members getOrDefault [_uid, objNull]) isNotEqualTo objNull) exitWith { _org }; + + ["org:members:add", [_orgID, _uid]] call EFUNC(extension,extCall) params ["_memberResult", "_memberSuccess"]; + if (!_memberSuccess) then { + ["WARNING", format ["Failed to add %1 to org %2 members: %3", _uid, _orgID, _memberResult]] call EFUNC(common,log); + }; + + private _memberName = _actor getOrDefault ["name", ""]; + if (_memberName isEqualTo "" && { _player isNotEqualTo objNull }) then { + _memberName = name _player; + }; + if (_memberName isEqualTo "") then { + _memberName = "Unknown"; + }; + + private _updatedMembers = +_members; + _updatedMembers set [_uid, createHashMapFromArray [["uid", _uid], ["name", _memberName]]]; + _org set ["members", _updatedMembers]; + + _org + }], + ["addMember", compileFinal { + params [["_orgID", "", [""]], ["_uid", "", [""]], ["_player", objNull, [objNull]], ["_actor", createHashMap, [createHashMap]]]; + + if (_orgID isEqualTo "" || { _uid isEqualTo "" }) exitWith { createHashMap }; + + private _org = GVAR(OrgStore) call ["loadById", [_orgID]]; + if (_org isEqualTo createHashMap) exitWith { _org }; + + _org = _self call ["verifyMember", [_org, _orgID, _uid, _player, _actor]]; + GVAR(Registry) set [_orgID, _org, true]; + + _org + }], + ["removeMember", compileFinal { + params [["_orgID", "", [""]], ["_uid", "", [""]]]; + + if (_orgID isEqualTo "" || { _uid isEqualTo "" }) exitWith { createHashMap }; + + private _org = GVAR(OrgStore) call ["loadById", [_orgID]]; + if (_org isEqualTo createHashMap) exitWith { _org }; + + ["org:members:remove", [_orgID, _uid]] call EFUNC(extension,extCall) params ["_memberResult", "_memberSuccess"]; + if (!_memberSuccess) exitWith { + ["WARNING", format ["Failed to remove %1 from org %2 members: %3", _uid, _orgID, _memberResult]] call EFUNC(common,log); + createHashMap + }; + + private _updatedMembers = +(_org getOrDefault ["members", createHashMap]); + _updatedMembers deleteAt _uid; + _org set ["members", _updatedMembers]; + GVAR(Registry) set [_orgID, _org, true]; + + _org + }], + ["restoreDefaultMembership", compileFinal { + params [["_uid", "", [""]], ["_player", objNull, [objNull]], ["_actor", createHashMap, [createHashMap]]]; + + private _result = _self call ["buildMembershipResult", []]; + if (_uid isEqualTo "") exitWith { + _result set ["message", "A valid player UID is required."]; + _result + }; + + private _resolvedPlayer = _player; + if (_resolvedPlayer isEqualTo objNull) then { + _resolvedPlayer = [_uid] call EFUNC(common,getPlayer); + }; + + private _resolvedActor = EGVAR(actor,Registry) getOrDefault [_uid, _actor]; + private _actorPatch = EGVAR(actor,ActorStore) call ["set", [EGVAR(actor,Registry), "actor:update", _uid, "organization", "default", true]]; + private _defaultActor = EGVAR(actor,Registry) getOrDefault [_uid, _resolvedActor]; + private _defaultOrg = _self call ["addMember", ["default", _uid, _resolvedPlayer, _defaultActor]]; + if (_defaultOrg isEqualTo createHashMap) exitWith { + _result set ["message", "Failed to restore default organization membership."]; + _result + }; + + GVAR(IndexRegistry) set [_uid, createHashMapFromArray [["orgID", "default"]]]; + _result set ["success", true]; + _result set ["actorPatch", _actorPatch]; + _result + }], + ["leave", compileFinal { + params [["_uid", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", ""], + ["actorPatch", createHashMap], + ["notification", []] + ]; + + if (_uid isEqualTo "") exitWith { + _result set ["message", "A valid player UID is required."]; + _result + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; + private _orgID = _actor getOrDefault ["organization", ""]; + if (_orgID isEqualTo "" || { toLower _orgID isEqualTo "default" }) exitWith { + _result set ["message", "You are already assigned to the default organization."]; + _result + }; + + private _org = GVAR(OrgStore) call ["loadById", [_orgID]]; + if (_org isEqualTo createHashMap) exitWith { + _result set ["message", "Unable to load organization data for leave request."]; + _result + }; + + private _ownerUid = _org getOrDefault ["owner", ""]; + if (_ownerUid isEqualTo _uid) exitWith { + _result set ["message", "Organization owners must disband the organization instead of leaving it."]; + _result + }; + + private _orgName = _org getOrDefault ["name", "Organization"]; + private _updatedOrg = _self call ["removeMember", [_orgID, _uid]]; + if (_updatedOrg isEqualTo createHashMap) exitWith { + _result set ["message", "Failed to remove you from the organization roster."]; + _result + }; + + private _defaultResult = _self call ["restoreDefaultMembership", [_uid, _player, _actor]]; + if !(_defaultResult getOrDefault ["success", false]) exitWith { + _result set ["message", _defaultResult getOrDefault ["message", "Failed to restore default organization membership."]]; + _result + }; + + private _message = format ["You left %1 and returned to the default organization.", _orgName]; + _result set ["success", true]; + _result set ["message", _message]; + _result set ["actorPatch", _defaultResult getOrDefault ["actorPatch", createHashMap]]; + _result set ["notification", ["info", "Organization Left", _message, 6000]]; + _result + }], + ["disband", compileFinal { + params [["_uid", "", [""]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", ""], + ["members", []] + ]; + + if (_uid isEqualTo "") exitWith { + _result set ["message", "A valid player UID is required."]; + _result + }; + + private _actor = EGVAR(actor,Registry) getOrDefault [_uid, createHashMap]; + private _orgID = _actor getOrDefault ["organization", ""]; + if (_orgID isEqualTo "" || { toLower _orgID isEqualTo "default" }) exitWith { + _result set ["message", "Only active player organizations can be disbanded."]; + _result + }; + + private _org = GVAR(OrgStore) call ["loadById", [_orgID]]; + if (_org isEqualTo createHashMap) exitWith { + _result set ["message", "Unable to load organization data for disbanding."]; + _result + }; + + private _ownerUid = _org getOrDefault ["owner", ""]; + if (_ownerUid isEqualTo "" || { _ownerUid isNotEqualTo _uid }) exitWith { + _result set ["message", "Only the organization owner can disband this organization."]; + _result + }; + + private _orgName = _org getOrDefault ["name", "Organization"]; + private _memberMap = _org getOrDefault ["members", createHashMap]; + private _memberUids = keys _memberMap; + if !(_uid in _memberUids) then { + _memberUids pushBack _uid; + }; + + private _deleteResult = GVAR(OrgStore) call ["delete", [_orgID]]; + if !(_deleteResult getOrDefault ["success", false]) exitWith { + _result set ["message", _deleteResult getOrDefault ["message", "Failed to disband organization."]]; + _result + }; + + private _memberResults = []; + { + private _memberUid = _x; + if (_memberUid isNotEqualTo "") then { + private _memberPlayer = [_memberUid] call EFUNC(common,getPlayer); + private _memberActor = EGVAR(actor,Registry) getOrDefault [_memberUid, createHashMap]; + private _defaultResult = _self call ["restoreDefaultMembership", [_memberUid, _memberPlayer, _memberActor]]; + if !(_defaultResult getOrDefault ["success", false]) then { + ["WARNING", format ["Failed to restore default org for %1 after disbanding %2: %3", _memberUid, _orgID, _defaultResult getOrDefault ["message", "Unknown error."]]] call EFUNC(common,log); + }; + + private _responseMessage = [ + format ["%1 has been disbanded.", _orgName], + format ["Your organization, %1, has been disbanded.", _orgName] + ] select (_memberUid isEqualTo _uid); + + private _notificationParams = [ + ["warning", "Organization Disbanded", _responseMessage, 6000], + ["success", "Organization Disbanded", _responseMessage, 6000] + ] select (_memberUid isEqualTo _uid); + + _memberResults pushBack (createHashMapFromArray [ + ["uid", _memberUid], + ["requester", _memberUid isEqualTo _uid], + ["message", _responseMessage], + ["notification", _notificationParams], + ["actorPatch", _defaultResult getOrDefault ["actorPatch", createHashMap]] + ]); + }; + } forEach _memberUids; + + _result set ["success", true]; + _result set ["message", format ["%1 has been disbanded.", _orgName]]; + _result set ["members", _memberResults]; + _result + }] +]; + +GVAR(OrgMembershipService) = createHashMapObject [GVAR(OrgMembershipServiceBase)]; diff --git a/arma/server/addons/org/functions/fnc_treasuryService.sqf b/arma/server/addons/org/functions/fnc_treasuryService.sqf new file mode 100644 index 0000000..33cffe8 --- /dev/null +++ b/arma/server/addons/org/functions/fnc_treasuryService.sqf @@ -0,0 +1,164 @@ +#include "..\script_component.hpp" + +#pragma hemtt ignore_variables ["_self"] +GVAR(OrgTreasuryServiceBase) = compileFinal createHashMapFromArray [ + ["#type", "OrgTreasuryService"], + ["buildChargeResult", compileFinal { + params [["_message", "Unable to process organization payment.", [""]]]; + + createHashMapFromArray [ + ["success", false], + ["message", _message], + ["patch", createHashMap], + ["memberUids", []] + ] + }], + ["resolveOrgMemberUids", compileFinal { + params [["_org", createHashMap, [createHashMap]], ["_requesterUid", "", [""]]]; + + private _memberUids = keys (_org getOrDefault ["members", createHashMap]); + if !(_requesterUid in _memberUids) then { _memberUids pushBack _requesterUid; }; + + _memberUids + }], + ["canManageTreasury", compileFinal { + params [["_orgID", "", [""]], ["_org", createHashMap, [createHashMap]], ["_requesterUid", "", [""]], ["_requesterPlayer", objNull, [objNull]]]; + + private _ownerUid = _org getOrDefault ["owner", ""]; + private _isDefaultOrg = (_orgID isEqualTo "default") || { toLowerANSI _ownerUid isEqualTo "server" }; + private _isDefaultOrgCeo = _isDefaultOrg + && { _requesterPlayer isNotEqualTo objNull } + && { toLowerANSI (vehicleVarName _requesterPlayer) isEqualTo "ceo" }; + + (_ownerUid isEqualTo _requesterUid) || _isDefaultOrgCeo + }], + ["assignCreditLine", compileFinal { + params [["_requesterUid", "", [""]], ["_memberUid", "", [""]], ["_memberName", "", [""]], ["_amount", 0, [0]]]; + + private _result = createHashMapFromArray [ + ["success", false], + ["message", ""], + ["patch", createHashMap], + ["memberUids", []] + ]; + + if ( + _requesterUid isEqualTo "" + || { _memberUid isEqualTo "" } + || { _amount <= 0 } + ) exitWith { + _result set ["message", "A valid requester, member, and credit amount are required."]; + _result + }; + + private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; + private _orgID = _requesterActor getOrDefault ["organization", "default"]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _org = GVAR(OrgStore) call ["loadById", [_orgID]]; + if (_org isEqualTo createHashMap) exitWith { + _result set ["message", "Unable to load organization data for credit line assignment."]; + _result + }; + + private _requesterPlayer = [_requesterUid] call EFUNC(common,getPlayer); + if !(_self call ["canManageTreasury", [_orgID, _org, _requesterUid, _requesterPlayer]]) exitWith { + _result set ["message", "Only the organization leader or CEO can manage treasury actions."]; + _result + }; + + private _members = _org getOrDefault ["members", createHashMap]; + private _memberRecord = _members getOrDefault [_memberUid, createHashMap]; + if (_memberRecord isEqualTo createHashMap) exitWith { + _result set ["message", "Selected member was not found in the organization roster."]; + _result + }; + + private _resolvedMemberName = _memberRecord getOrDefault ["name", _memberName]; + if (_resolvedMemberName isEqualTo "") then { _resolvedMemberName = _memberName; }; + + private _creditLines = +(_org getOrDefault ["credit_lines", createHashMap]); + _creditLines set [_memberUid, createHashMapFromArray [ + ["uid", _memberUid], + ["name", _resolvedMemberName], + ["amount", _amount] + ]]; + + private _patch = GVAR(OrgStore) call ["set", [GVAR(Registry), "org:update", _orgID, "credit_lines", _creditLines, true]]; + private _memberUids = _self call ["resolveOrgMemberUids", [_org, _requesterUid]]; + + _result set ["success", true]; + _result set ["message", format ["Credit line of $%1 assigned to %2.", [_amount] call BIS_fnc_numberText, _resolvedMemberName]]; + _result set ["patch", _patch]; + _result set ["memberUids", _memberUids]; + _result + }], + ["chargeCheckout", compileFinal { + params [["_requesterUid", "", [""]], ["_requesterPlayer", objNull, [objNull]], ["_source", "org_funds", [""]], ["_amount", 0, [0]], ["_commit", false, [false]]]; + + private _result = _self call ["buildChargeResult", []]; + private _requesterActor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap]; + private _orgID = _requesterActor getOrDefault ["organization", "default"]; + if (_orgID isEqualTo "") then { _orgID = "default"; }; + + private _org = GVAR(Registry) getOrDefault [_orgID, createHashMap]; + if (_org isEqualTo createHashMap) exitWith { + _result set ["message", "Organization data is unavailable for checkout."]; + _result + }; + + private _memberUids = _self call ["resolveOrgMemberUids", [_org, _requesterUid]]; + + switch (toLowerANSI _source) do { + case "org_funds": { + if !(_self call ["canManageTreasury", [_orgID, _org, _requesterUid, _requesterPlayer]]) exitWith { + _result set ["message", "Only the organization leader or CEO can charge org funds."]; + _result + }; + + private _funds = _org getOrDefault ["funds", 0]; + if (_funds < _amount) exitWith { + _result set ["message", "Organization funds cannot cover this checkout."]; + _result + }; + + private _patch = createHashMapFromArray [["funds", (_funds - _amount)]]; + if (_commit) then { _patch = GVAR(OrgStore) call ["mset", [GVAR(Registry), "org:update", _orgID, _patch, false]]; }; + + _result set ["success", true]; + _result set ["message", ""]; + _result set ["patch", _patch]; + _result set ["memberUids", _memberUids]; + _result + }; + case "credit_line": { + private _creditLines = +(_org getOrDefault ["credit_lines", createHashMap]); + private _memberCredit = +(_creditLines getOrDefault [_requesterUid, createHashMap]); + private _creditAmount = _memberCredit getOrDefault ["amount", 0]; + if (_creditAmount < _amount) exitWith { + _result set ["message", "Assigned credit line cannot cover this checkout."]; + _result + }; + + _memberCredit set ["uid", _requesterUid]; + _memberCredit set ["amount", (_creditAmount - _amount)]; + _creditLines set [_requesterUid, _memberCredit]; + + private _patch = createHashMapFromArray [["credit_lines", _creditLines]]; + if (_commit) then { _patch = GVAR(OrgStore) call ["mset", [GVAR(Registry), "org:update", _orgID, _patch, false]]; }; + + _result set ["success", true]; + _result set ["message", ""]; + _result set ["patch", _patch]; + _result set ["memberUids", _memberUids]; + _result + }; + default { + _result set ["message", "Selected organization payment source is unsupported."]; + _result + }; + }; + }] +]; + +GVAR(OrgTreasuryService) = createHashMapObject [GVAR(OrgTreasuryServiceBase)]; diff --git a/arma/server/addons/store/$PBOPREFIX$ b/arma/server/addons/store/$PBOPREFIX$ new file mode 100644 index 0000000..ed419a2 --- /dev/null +++ b/arma/server/addons/store/$PBOPREFIX$ @@ -0,0 +1 @@ +forge\forge_server\addons\store diff --git a/arma/server/addons/store/CfgEventHandlers.hpp b/arma/server/addons/store/CfgEventHandlers.hpp new file mode 100644 index 0000000..f6503c2 --- /dev/null +++ b/arma/server/addons/store/CfgEventHandlers.hpp @@ -0,0 +1,17 @@ +class Extended_PreStart_EventHandlers { + class ADDON { + init = QUOTE(call COMPILE_SCRIPT(XEH_preStart)); + }; +}; + +class Extended_PreInit_EventHandlers { + class ADDON { + init = QUOTE(call COMPILE_SCRIPT(XEH_preInit)); + }; +}; + +class Extended_PostInit_EventHandlers { + class ADDON { + init = QUOTE(call COMPILE_SCRIPT(XEH_postInit)); + }; +}; diff --git a/arma/server/addons/store/README.md b/arma/server/addons/store/README.md new file mode 100644 index 0000000..84beb40 --- /dev/null +++ b/arma/server/addons/store/README.md @@ -0,0 +1,3 @@ +# forge_server_store + +Description for this addon diff --git a/arma/server/addons/store/XEH_PREP.hpp b/arma/server/addons/store/XEH_PREP.hpp new file mode 100644 index 0000000..cf3f040 --- /dev/null +++ b/arma/server/addons/store/XEH_PREP.hpp @@ -0,0 +1,2 @@ +PREP(initCatalogService); +PREP(initStoreStore); diff --git a/arma/server/addons/store/XEH_postInit.sqf b/arma/server/addons/store/XEH_postInit.sqf new file mode 100644 index 0000000..b911595 --- /dev/null +++ b/arma/server/addons/store/XEH_postInit.sqf @@ -0,0 +1,3 @@ +#include "script_component.hpp" + +// call FUNC(initStore); diff --git a/arma/server/addons/store/XEH_preInit.sqf b/arma/server/addons/store/XEH_preInit.sqf new file mode 100644 index 0000000..54bfe32 --- /dev/null +++ b/arma/server/addons/store/XEH_preInit.sqf @@ -0,0 +1,39 @@ +#include "script_component.hpp" + +PREP_RECOMPILE_START; +#include "XEH_PREP.hpp" +PREP_RECOMPILE_END; + +// private _category = [QUOTE(MOD_NAME), LLSTRING(displayName)]; + +[QGVAR(requestCategory), { + params [["_uid", "", [""]], ["_category", "", [""]]]; + + if (_uid isEqualTo "" || { _category isEqualTo "" }) exitWith { + diag_log "[FORGE:Server:Store] Invalid category request payload." + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith {}; + + if (isNil QGVAR(StoreCatalogService)) exitWith { + diag_log "[FORGE:Server:Store] Store catalog service is unavailable." + }; + + private _result = GVAR(StoreCatalogService) call ["buildCategoryResponse", [_category]]; + [CRPC(store,responseCategory), [_result], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); + +[QGVAR(requestCheckout), { + params [["_uid", "", [""]], ["_payloadJson", "", [""]]]; + + if (_uid isEqualTo "" || { _payloadJson isEqualTo "" }) exitWith { + diag_log "[FORGE:Server:Store] Invalid checkout request payload." + }; + + private _player = [_uid] call EFUNC(common,getPlayer); + if (_player isEqualTo objNull) exitWith {}; + + private _result = GVAR(StoreStore) call ["checkout", [_uid, _player, _payloadJson]]; + [CRPC(store,responseCheckout), [_result], _player] call CFUNC(targetEvent); +}] call CFUNC(addEventHandler); diff --git a/arma/server/addons/store/XEH_preStart.sqf b/arma/server/addons/store/XEH_preStart.sqf new file mode 100644 index 0000000..a51262a --- /dev/null +++ b/arma/server/addons/store/XEH_preStart.sqf @@ -0,0 +1,2 @@ +#include "script_component.hpp" +#include "XEH_PREP.hpp" diff --git a/arma/server/addons/store/config.cpp b/arma/server/addons/store/config.cpp new file mode 100644 index 0000000..cbd0a75 --- /dev/null +++ b/arma/server/addons/store/config.cpp @@ -0,0 +1,20 @@ +#include "script_component.hpp" + +class CfgPatches { + class ADDON { + author = AUTHOR; + authors[] = {"J.Schmidt"}; + url = ECSTRING(main,url); + name = COMPONENT_NAME; + requiredVersion = REQUIRED_VERSION; + requiredAddons[] = { + "forge_server_main", + "forge_server_common" + }; + units[] = {}; + weapons[] = {}; + VERSION_CONFIG; + }; +}; + +#include "CfgEventHandlers.hpp" diff --git a/arma/server/addons/store/functions/fnc_initCatalogService.sqf b/arma/server/addons/store/functions/fnc_initCatalogService.sqf new file mode 100644 index 0000000..2e81ba4 --- /dev/null +++ b/arma/server/addons/store/functions/fnc_initCatalogService.sqf @@ -0,0 +1,460 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initCatalogService.sqf + * Author: IDSolutions + * Date: 2026-03-14 + * Public: No + * + * Description: + * Initializes the server-side store catalog service for authoritative category hydration and pricing. + */ + +#pragma hemtt ignore_variables ["_self"] +GVAR(StoreCatalogServiceBaseClass) = compileFinal createHashMapFromArray [ + ["#type", "StoreCatalogServiceBaseClass"], + ["#create", compileFinal { + _self set ["catalogCache", createHashMap]; + ["INFO", "Store catalog service initialized!"] call EFUNC(common,log); + }], + ["formatCurrency", compileFinal { + params [["_amount", 0, [0]]]; + + format ["$%1", [_amount max 0] call BIS_fnc_numberText] + }], + ["isVisibleConfig", compileFinal { + params [["_cfg", configNull, [configNull]]]; + + isClass _cfg + && { getNumber (_cfg >> "scope") >= 2 } + && { (getText (_cfg >> "displayName")) isNotEqualTo "" } + }], + ["buildDescription", compileFinal { + params [["_cfg", configNull, [configNull]], ["_fallback", "", [""]]]; + + private _description = getText (_cfg >> "descriptionShort"); + if (_description isEqualTo "") then { _description = _fallback; }; + + _description + }], + ["normalizeCategoryKey", compileFinal { + params [["_category", "", [""]]]; + + private _categoryKey = toLowerANSI _category; + if (_categoryKey isEqualTo "items") exitWith { "misc" }; + + _categoryKey + }], + ["calculateCatalogPriceValue", compileFinal { + params [ + ["_cfg", configNull, [configNull]], + ["_isVehicle", false, [false]] + ]; + + if (isNull _cfg) exitWith { 50 }; + + private _mass = 0; + private _priceValue = 0; + + if (_isVehicle) then { + _priceValue = getNumber (_cfg >> "cost"); + } else { + private _weaponType = getNumber (_cfg >> "type"); + if (_weaponType in [1, 2, 4]) then { _mass = getNumber (_cfg >> "WeaponSlotsInfo" >> "mass"); }; + if (_mass <= 0) then { _mass = getNumber (_cfg >> "ItemInfo" >> "mass"); }; + if (_mass <= 0) then { _mass = getNumber (_cfg >> "mass"); }; + + _priceValue = ceil ((_mass max 0) * 7.5); + }; + + _priceValue max 50 + }], + ["buildCatalogItem", compileFinal { + params [ + ["_cfg", configNull, [configNull]], + ["_typeLabel", "", [""]], + ["_fallbackDescription", "", [""]], + ["_imageField", "picture", [""]], + ["_isVehicle", false, [false]] + ]; + + if (isNull _cfg) exitWith { createHashMap }; + + private _className = configName _cfg; + private _displayName = getText (_cfg >> "displayName"); + private _picture = getText (_cfg >> _imageField); + if (_picture isEqualTo "" && { _imageField isNotEqualTo "picture" }) then { + _picture = getText (_cfg >> "picture"); + }; + + private _priceValue = _self call ["calculateCatalogPriceValue", [_cfg, _isVehicle]]; + + createHashMapFromArray [ + ["className", _className], + ["code", _className], + ["name", _displayName], + ["description", _self call ["buildDescription", [_cfg, _fallbackDescription]]], + ["price", _self call ["formatCurrency", [_priceValue]]], + ["priceValue", _priceValue], + ["image", _picture], + ["type", _typeLabel] + ] + }], + ["appendCfgWeaponsByItemInfoType", compileFinal { + params [["_items", [], [[]]], ["_itemInfoType", -1, [0]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]]; + + { + private _cfg = _x; + if ( + _self call ["isVisibleConfig", [_cfg]] + && { getNumber (_cfg >> "ItemInfo" >> "type") isEqualTo _itemInfoType } + ) then { + _items pushBack (_self call ["buildCatalogItem", [_cfg, _typeLabel, _fallbackDescription]]); + }; + } forEach ("true" configClasses (configFile >> "CfgWeapons")); + + _items + }], + ["appendCfgWeaponsByType", compileFinal { + params [["_items", [], [[]]], ["_weaponType", -1, [0]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]]; + + { + private _cfg = _x; + if ( + _self call ["isVisibleConfig", [_cfg]] + && { getNumber (_cfg >> "type") isEqualTo _weaponType } + ) then { + _items pushBack (_self call ["buildCatalogItem", [_cfg, _typeLabel, _fallbackDescription]]); + }; + } forEach ("true" configClasses (configFile >> "CfgWeapons")); + + _items + }], + ["isAceClassName", compileFinal { + params [["_cfg", configNull, [configNull]]]; + + ((toLowerANSI (configName _cfg)) select [0, 4]) isEqualTo "ace_" + }], + ["isAttachmentConfig", compileFinal { + params [["_cfg", configNull, [configNull]]]; + + if !(_self call ["isVisibleConfig", [_cfg]]) exitWith { false }; + if (_self call ["isAceClassName", [_cfg]]) exitWith { false }; + + private _className = configName _cfg; + private _itemType = [_className] call BIS_fnc_itemType; + private _group = toLowerANSI (_itemType param [0, ""]); + private _kind = toLowerANSI (_itemType param [1, ""]); + + (_group find "accessory") >= 0 + || { (_kind find "accessory") >= 0 } + || { _kind in ["accessorymuzzle", "accessorypointer", "accessorysights", "accessorybipod"] } + }], + ["resolveAttachmentTypeLabel", compileFinal { + params [["_cfg", configNull, [configNull]]]; + + private _className = configName _cfg; + private _itemType = [_className] call BIS_fnc_itemType; + private _kind = toLowerANSI (_itemType param [1, ""]); + + if ((_kind find "muzzle") >= 0) exitWith { "Muzzle Attachment" }; + if ((_kind find "optic") >= 0 || { (_kind find "sight") >= 0 }) exitWith { "Optic Attachment" }; + if ((_kind find "pointer") >= 0 || { (_kind find "flash") >= 0 } || { (_kind find "light") >= 0 }) exitWith { "Light Attachment" }; + if ((_kind find "bipod") >= 0) exitWith { "Bipod Attachment" }; + + "Attachment" + }], + ["appendCfgAttachments", compileFinal { + params [["_items", [], [[]]], ["_fallbackDescription", "", [""]]]; + + { + private _cfg = _x; + if (_self call ["isAttachmentConfig", [_cfg]]) then { + private _typeLabel = _self call ["resolveAttachmentTypeLabel", [_cfg]]; + _items pushBack (_self call ["buildCatalogItem", [_cfg, _typeLabel, _fallbackDescription]]); + }; + } forEach ("true" configClasses (configFile >> "CfgWeapons")); + + _items + }], + ["appendCfgVehiclesByKind", compileFinal { + params [["_items", [], [[]]], ["_baseClass", "", [""]], ["_typeLabel", "", [""]], ["_fallbackDescription", "", [""]]]; + + { + private _cfg = _x; + private _className = configName _cfg; + if ( + _self call ["isVisibleConfig", [_cfg]] + && { getNumber (_cfg >> "isBackpack") isEqualTo 0 } + && { !(_className isKindOf ["CAManBase", configFile >> "CfgVehicles"]) } + && { !(_className isKindOf ["StaticWeapon", configFile >> "CfgVehicles"]) } + && { _className isKindOf [_baseClass, configFile >> "CfgVehicles"] } + ) then { + _items pushBack (_self call ["buildCatalogItem", [_cfg, _typeLabel, _fallbackDescription, "editorPreview", true]]); + }; + } forEach ("true" configClasses (configFile >> "CfgVehicles")); + + _items + }], + ["isBackpackConfig", compileFinal { + params [["_cfg", configNull, [configNull]]]; + + getNumber (_cfg >> "isBackpack") isEqualTo 1 + || { getNumber (_cfg >> "ItemInfo" >> "type") isEqualTo TYPE_BACKPACK } + }], + ["appendCfgBackpacks", compileFinal { + params [["_items", [], [[]]], ["_typeLabel", "Backpack", [""]], ["_fallbackDescription", "", [""]]]; + + { + private _cfg = _x; + if ( + _self call ["isVisibleConfig", [_cfg]] + && { _self call ["isBackpackConfig", [_cfg]] } + ) then { + _items pushBack (_self call ["buildCatalogItem", [_cfg, _typeLabel, _fallbackDescription]]); + }; + } forEach ("true" configClasses (configFile >> "CfgVehicles")); + + _items + }], + ["scanCategoryItems", compileFinal { + params [["_category", "", [""]]]; + + private _categoryKey = _self call ["normalizeCategoryKey", [_category]]; + if (_categoryKey isEqualTo "") exitWith { [] }; + + private _items = []; + + switch (_categoryKey) do { + case "uniforms": { _items = _self call ["appendCfgWeaponsByItemInfoType", [_items, TYPE_UNIFORM, "Uniform", "Live uniform entry generated from the game inventory."]]; }; + case "headgear": { _items = _self call ["appendCfgWeaponsByItemInfoType", [_items, TYPE_HEADGEAR, "Headgear", "Live headgear entry generated from the game inventory."]]; }; + case "vests": { _items = _self call ["appendCfgWeaponsByItemInfoType", [_items, TYPE_VEST, "Vest", "Live vest entry generated from the game inventory."]]; }; + case "backpacks": { _items = _self call ["appendCfgBackpacks", [_items, "Backpack", "Live backpack entry generated from the game inventory."]]; }; + case "attachments": { + _items = _self call ["appendCfgAttachments", [_items, "Live attachment entry generated from the game inventory."]]; + }; + case "facewear": { + { if (_self call ["isVisibleConfig", [_x]]) then { _items pushBack (_self call ["buildCatalogItem", [_x, "Facewear", "Live facewear entry generated from the game inventory."]]); }; } forEach ("true" configClasses (configFile >> "CfgGlasses")); + }; + case "ammo": { + { if (_self call ["isVisibleConfig", [_x]]) then { _items pushBack (_self call ["buildCatalogItem", [_x, "Magazine", "Live ammunition entry generated from the game inventory."]]); }; } forEach ("true" configClasses (configFile >> "CfgMagazines")); + }; + case "misc": { + { + private _cfg = _x; + private _className = configName _cfg; + private _itemType = [_className] call BIS_fnc_itemType; + private _group = _itemType param [0, ""]; + private _kind = _itemType param [1, ""]; + private _weaponType = getNumber (_cfg >> "type"); + private _isAceClass = _self call ["isAceClassName", [_cfg]]; + + if ( + _self call ["isVisibleConfig", [_cfg]] + && { !(_weaponType in [1, 2, 4]) } + && { (_group in ["Item", "Equipment"]) || { _isAceClass } } + && { !(_kind in ["Uniform", "Vest", "Headgear"]) } + && { !(_self call ["isAttachmentConfig", [_cfg]]) } + && { (getNumber (_cfg >> "ItemInfo" >> "type") isNotEqualTo TYPE_BACKPACK) } + ) then { + private _typeLabel = [_kind, "Item"] select (_kind isEqualTo ""); + _items pushBack (_self call ["buildCatalogItem", [_cfg, _typeLabel, "Live utility entry generated from the game inventory."]]); + }; + } forEach ("true" configClasses (configFile >> "CfgWeapons")); + }; + case "primary": { _items = _self call ["appendCfgWeaponsByType", [_items, 1, "Primary Weapon", "Live primary weapon entry generated from the game inventory."]]; }; + case "handgun": { _items = _self call ["appendCfgWeaponsByType", [_items, 2, "Handgun", "Live sidearm entry generated from the game inventory."]]; }; + case "secondary": { _items = _self call ["appendCfgWeaponsByType", [_items, 4, "Launcher", "Live launcher entry generated from the game inventory."]]; }; + case "cars": { _items = _self call ["appendCfgVehiclesByKind", [_items, "Car", "Vehicle", "Live wheeled vehicle entry generated from the game inventory."]]; }; + case "armor": { _items = _self call ["appendCfgVehiclesByKind", [_items, "Tank", "Vehicle", "Live armored vehicle entry generated from the game inventory."]]; }; + case "helis": { _items = _self call ["appendCfgVehiclesByKind", [_items, "Helicopter", "Aircraft", "Live helicopter entry generated from the game inventory."]]; }; + case "planes": { _items = _self call ["appendCfgVehiclesByKind", [_items, "Plane", "Aircraft", "Live fixed-wing entry generated from the game inventory."]]; }; + case "naval": { _items = _self call ["appendCfgVehiclesByKind", [_items, "Ship", "Naval", "Live naval vehicle entry generated from the game inventory."]]; }; + case "other": { + { + private _cfg = _x; + private _className = configName _cfg; + private _isSupportedVehicle = _className isKindOf ["AllVehicles", configFile >> "CfgVehicles"]; + private _isKnownCategory = + _className isKindOf ["Car", configFile >> "CfgVehicles"] + || { _className isKindOf ["Tank", configFile >> "CfgVehicles"] } + || { _className isKindOf ["Helicopter", configFile >> "CfgVehicles"] } + || { _className isKindOf ["Plane", configFile >> "CfgVehicles"] } + || { _className isKindOf ["Ship", configFile >> "CfgVehicles"] }; + + if ( + _self call ["isVisibleConfig", [_cfg]] + && { _isSupportedVehicle } + && { !_isKnownCategory } + && { getNumber (_cfg >> "isBackpack") isEqualTo 0 } + && { !(_className isKindOf ["CAManBase", configFile >> "CfgVehicles"]) } + && { !(_className isKindOf ["StaticWeapon", configFile >> "CfgVehicles"]) } + ) then { + _items pushBack (_self call ["buildCatalogItem", [_cfg, "Special Vehicle", "Live specialty vehicle entry generated from the game inventory.", "editorPreview", true]]); + }; + } forEach ("true" configClasses (configFile >> "CfgVehicles")); + }; + }; + + private _sortedItems = _items apply { [toLowerANSI (_x getOrDefault ["name", ""]), _x] }; + _sortedItems sort true; + _sortedItems apply { _x select 1 } + }], + ["isVehicleCategory", compileFinal { + params [["_category", "", [""]]]; + + (toLowerANSI _category) in ["cars", "armor", "helis", "planes", "naval", "other"] + }], + ["buildPayloadCategory", compileFinal { + params [["_category", "", [""]]]; + + switch (toLowerANSI _category) do { + case "backpacks": { "backpack" }; + case "attachments": { "attachment" }; + case "ammo": { "magazine" }; + case "primary"; + case "secondary"; + case "handgun": { "weapon" }; + case "cars"; + case "armor"; + case "helis"; + case "planes"; + case "naval"; + case "other": { toLowerANSI _category }; + default { "item" }; + } + }], + ["isSupportedCategory", compileFinal { + params [["_category", "", [""]]]; + + (_self call ["normalizeCategoryKey", [_category]]) in ["uniforms", "headgear", "vests", "backpacks", "attachments", "facewear", "ammo", "misc", "primary", "handgun", "secondary", "cars", "armor", "helis", "planes", "naval", "other"] + }], + ["buildCategoryItems", compileFinal { + params [["_category", "", [""]]]; + + private _categoryKey = _self call ["normalizeCategoryKey", [_category]]; + if (_categoryKey isEqualTo "") exitWith { [] }; + + private _catalogCache = _self getOrDefault ["catalogCache", createHashMap]; + if (_categoryKey in (keys _catalogCache)) exitWith { _catalogCache get _categoryKey }; + + private _items = _self call ["scanCategoryItems", [_categoryKey]]; + private _payloadCategory = _self call ["buildPayloadCategory", [_categoryKey]]; + private _entryKind = ["item", "vehicle"] select (_self call ["isVehicleCategory", [_categoryKey]]); + + { + _x set ["category", _payloadCategory]; + _x set ["entryKind", _entryKind]; + } forEach _items; + + _catalogCache set [_categoryKey, _items]; + _self set ["catalogCache", _catalogCache]; + + _items + }], + ["buildCategoryResponse", compileFinal { + params [["_category", "", [""]]]; + + private _categoryKey = _self call ["normalizeCategoryKey", [_category]]; + private _response = createHashMapFromArray [["success", false], ["category", _categoryKey], ["items", []], ["message", "No store category was provided."]]; + + if (_categoryKey isEqualTo "") exitWith { _response }; + if !(_self call ["isSupportedCategory", [_categoryKey]]) exitWith { + _response set ["message", format ["Unsupported store category: %1", _categoryKey]]; + _response + }; + + _response set ["success", true]; + _response set ["message", ""]; + _response set ["items", _self call ["buildCategoryItems", [_categoryKey]]]; + _response + }], + ["resolveCheckoutCategories", compileFinal { + params [["_entry", createHashMap, [createHashMap]]]; + + private _entryKind = toLowerANSI (_entry getOrDefault ["entryKind", "item"]); + private _category = toLowerANSI (_entry getOrDefault ["category", ""]); + + if (_entryKind isEqualTo "vehicle") exitWith { ["cars", "armor", "helis", "planes", "naval", "other"] }; + if (_category isEqualTo "weapon") exitWith { ["primary", "handgun", "secondary"] }; + if (_category isEqualTo "backpack") exitWith { ["backpacks"] }; + if (_category isEqualTo "attachment") exitWith { ["attachments"] }; + if (_category isEqualTo "magazine") exitWith { ["ammo"] }; + + ["uniforms", "headgear", "vests", "facewear", "misc", "attachments", "backpacks"] + }], + ["resolveCheckoutCatalogEntry", compileFinal { + params [["_entry", createHashMap, [createHashMap]]]; + + private _className = toLowerANSI (_entry getOrDefault ["classname", ""]); + if (_className isEqualTo "") exitWith { createHashMap }; + + private _resolved = createHashMap; + { + private _catalogEntries = _self call ["buildCategoryItems", [_x]]; + private _match = _catalogEntries select { (toLowerANSI (_x getOrDefault ["className", ""])) isEqualTo _className }; + + if (_match isNotEqualTo []) exitWith { _resolved = _match select 0; }; + } forEach (_self call ["resolveCheckoutCategories", [_entry]]); + + _resolved + }], + ["calculateCheckoutTotal", compileFinal { + params [["_items", [], [[]]], ["_vehicles", [], [[]]]]; + + private _result = createHashMapFromArray [["success", false], ["total", 0], ["message", "Checkout total must be greater than zero."]]; + private _total = 0; + private _message = ""; + + { + if (_message isEqualTo "") then { + private _className = _x getOrDefault ["classname", ""]; + private _quantity = floor ((_x getOrDefault ["quantity", 1]) max 0); + + if (_className isEqualTo "" || { _quantity <= 0 }) then { + _message = "Checkout contains an invalid item entry."; + } else { + private _catalogEntry = _self call ["resolveCheckoutCatalogEntry", [createHashMapFromArray [["classname", _className], ["category", _x getOrDefault ["category", "item"]], ["entryKind", "item"]]]]; + + if (_catalogEntry isEqualTo createHashMap) then { + _message = format ["Unsupported store item: %1", _className]; + } else { + _total = _total + ((_catalogEntry getOrDefault ["priceValue", 0]) * _quantity); + }; + }; + }; + } forEach _items; + + { + if (_message isEqualTo "") then { + private _className = _x getOrDefault ["classname", ""]; + if (_className isEqualTo "") then { + _message = "Checkout contains an invalid vehicle entry."; + } else { + private _catalogEntry = _self call ["resolveCheckoutCatalogEntry", [createHashMapFromArray [["classname", _className], ["category", _x getOrDefault ["category", ""]], ["entryKind", "vehicle"]]]]; + + if (_catalogEntry isEqualTo createHashMap) then { + _message = format ["Unsupported store vehicle: %1", _className]; + } else { + _total = _total + (_catalogEntry getOrDefault ["priceValue", 0]); + }; + }; + }; + } forEach _vehicles; + + if (_message isNotEqualTo "") exitWith { + _result set ["message", _message]; + _result + }; + + if (_total <= 0) exitWith { _result }; + + _result set ["success", true]; + _result set ["total", floor _total]; + _result set ["message", ""]; + _result + }] +]; + +GVAR(StoreCatalogService) = createHashMapObject [GVAR(StoreCatalogServiceBaseClass)]; +GVAR(StoreCatalogService) diff --git a/arma/server/addons/store/functions/fnc_initStoreStore.sqf b/arma/server/addons/store/functions/fnc_initStoreStore.sqf new file mode 100644 index 0000000..6e99752 --- /dev/null +++ b/arma/server/addons/store/functions/fnc_initStoreStore.sqf @@ -0,0 +1,201 @@ +#include "..\script_component.hpp" + +/* + * File: fnc_initStoreStore.sqf + * Author: IDSolutions + * Date: 2026-03-12 + * Last Update: 2026-03-14 + * Public: No + * + * Description: + * Initializes the server-side store checkout flow. + */ + +if (isNil QGVAR(StoreCatalogService)) then { call FUNC(initCatalogService); }; + +#pragma hemtt ignore_variables ["_self"] +GVAR(StoreBaseStore) = compileFinal createHashMapFromArray [ + ["#type", "StoreBaseStore"], + ["#create", compileFinal { + ["INFO", "Store checkout service initialized!"] call EFUNC(common,log); + }], + ["buildResult", compileFinal { + params [["_message", "Checkout failed.", [""]], ["_paymentMethod", "cash", [""]]]; + + createHashMapFromArray [ + ["success", false], + ["message", _message], + ["paymentMethod", _paymentMethod], + ["chargedTotal", 0], + ["lockerGranted", []], + ["vehicleGranted", []], + ["bankPatch", createHashMap], + ["orgPatch", createHashMap], + ["orgTargetUids", []] + ] + }], + ["formatCurrency", compileFinal { + params [["_amount", 0, [0]]]; + + format ["$%1", [_amount max 0] call BIS_fnc_numberText] + }], + ["applyPaymentPatch", compileFinal { + params [["_uid", "", [""]], ["_player", objNull, [objNull]], ["_paymentMethod", "cash", [""]], ["_total", 0, [0]], ["_commit", false, [false]]]; + + private _result = _self call ["buildResult", ["Unable to process payment.", _paymentMethod]]; + private _payment = switch (toLowerANSI _paymentMethod) do { + case "cash"; + case "bank": { + EGVAR(bank,BankStore) call ["chargeCheckout", [_uid, _paymentMethod, _total, _commit]] + }; + case "org_funds"; + case "credit_line": { + EGVAR(org,OrgStore) call ["chargeCheckout", [_uid, _player, _paymentMethod, _total, _commit]] + }; + default { + createHashMapFromArray [ + ["success", false], + ["message", "Selected payment source is unsupported."], + ["patch", createHashMap], + ["memberUids", []] + ] + }; + }; + + if !(_payment getOrDefault ["success", false]) exitWith { + _result set ["message", _payment getOrDefault ["message", "Unable to process payment."]]; + _result + }; + + private _patch = _payment getOrDefault ["patch", createHashMap]; + if ((_paymentMethod isEqualTo "cash") || { _paymentMethod isEqualTo "bank" }) then { + _result set ["bankPatch", _patch]; + } else { + _result set ["orgPatch", _patch]; + _result set ["orgTargetUids", _payment getOrDefault ["memberUids", []]]; + }; + + _result set ["success", true]; + _result set ["message", ""]; + _result + }], + ["checkout", compileFinal { + params [["_uid", "", [""]], ["_player", objNull, [objNull]], ["_payloadJson", "", [""]]]; + + private _result = _self call ["buildResult", ["Checkout failed.", "cash"]]; + private _payload = fromJSON _payloadJson; + if !(_payload isEqualType createHashMap) exitWith { + _result set ["message", "Checkout request payload is invalid."]; + _result + }; + + private _paymentMethod = toLowerANSI (_payload getOrDefault ["paymentMethod", "cash"]); + private _items = _payload getOrDefault ["items", []]; + private _vehicles = _payload getOrDefault ["vehicles", []]; + + if (isNil QGVAR(StoreCatalogService)) exitWith { + _result set ["message", "Store catalog service is unavailable."]; + _result + }; + + private _priceResult = GVAR(StoreCatalogService) call ["calculateCheckoutTotal", [_items, _vehicles]]; + private _totalPrice = _priceResult getOrDefault ["total", 0]; + + _result set ["paymentMethod", _paymentMethod]; + _result set ["chargedTotal", _totalPrice]; + + if (_items isEqualTo [] && { _vehicles isEqualTo [] }) exitWith { + _result set ["message", "Add at least one item before checkout."]; + _result + }; + + if !(_priceResult getOrDefault ["success", false]) exitWith { + _result set ["message", _priceResult getOrDefault ["message", "Checkout total must be greater than zero."]]; + _result + }; + + private _lockerPreview = EGVAR(locker,LockerStore) call ["grantItems", [_uid, _items, false]]; + if !(_lockerPreview getOrDefault ["success", false]) exitWith { + _result set ["message", _lockerPreview getOrDefault ["message", "Locker grant failed."]]; + _result + }; + + private _vaPreview = EGVAR(locker,VAStore) call ["unlockItems", [_uid, _items, false]]; + if !(_vaPreview getOrDefault ["success", false]) exitWith { + _result set ["message", _vaPreview getOrDefault ["message", "VA unlock failed."]]; + _result + }; + + private _vgPreview = EGVAR(garage,VGarageStore) call ["grantVehicles", [_uid, _vehicles, false]]; + if !(_vgPreview getOrDefault ["success", false]) exitWith { + _result set ["message", _vgPreview getOrDefault ["message", "Vehicle unlock failed."]]; + _result + }; + + private _orgFleetPreview = createHashMapFromArray [["success", true], ["message", ""], ["patch", createHashMap], ["memberUids", []]]; + if (_paymentMethod isEqualTo "org_funds" && { _vehicles isNotEqualTo [] }) then { + _orgFleetPreview = EGVAR(org,OrgStore) call ["addFleetVehicles", [_uid, _vehicles, false]]; + if !(_orgFleetPreview getOrDefault ["success", false]) exitWith { + _result set ["message", _orgFleetPreview getOrDefault ["message", "Organization fleet update failed."]]; + _result + }; + }; + + _result set ["lockerGranted", _lockerPreview getOrDefault ["granted", []]]; + _result set ["vehicleGranted", _vgPreview getOrDefault ["granted", []]]; + + private _paymentPreview = _self call ["applyPaymentPatch", [_uid, _player, _paymentMethod, _totalPrice, false]]; + if !(_paymentPreview getOrDefault ["success", false]) exitWith { + _result set ["message", _paymentPreview getOrDefault ["message", "Payment failed."]]; + _result + }; + + private _payment = _self call ["applyPaymentPatch", [_uid, _player, _paymentMethod, _totalPrice, true]]; + private _lockerResult = EGVAR(locker,LockerStore) call ["grantItems", [_uid, _items, true]]; + private _vaResult = EGVAR(locker,VAStore) call ["unlockItems", [_uid, _items, true]]; + private _vgResult = EGVAR(garage,VGarageStore) call ["grantVehicles", [_uid, _vehicles, true]]; + private _orgFleetResult = createHashMapFromArray [["success", true], ["message", ""], ["patch", createHashMap], ["memberUids", []]]; + if (_paymentMethod isEqualTo "org_funds" && { _vehicles isNotEqualTo [] }) then { + _orgFleetResult = EGVAR(org,OrgStore) call ["addFleetVehicles", [_uid, _vehicles, true]]; + }; + + private _lockerPatch = _lockerResult getOrDefault ["patch", createHashMap]; + private _vaPatch = _vaResult getOrDefault ["patch", createHashMap]; + private _vgPatch = _vgResult getOrDefault ["patch", createHashMap]; + if (keys _lockerPatch isNotEqualTo []) then { [CRPC(locker,responseSyncLocker), [_lockerPatch], _player] call CFUNC(targetEvent); }; + if (keys _vaPatch isNotEqualTo []) then { [CRPC(locker,responseSyncVA), [_vaPatch], _player] call CFUNC(targetEvent); }; + if (keys _vgPatch isNotEqualTo []) then { [CRPC(garage,responseSyncVG), [_vgPatch], _player] call CFUNC(targetEvent); }; + + private _bankPatch = _payment getOrDefault ["bankPatch", createHashMap]; + if (keys _bankPatch isNotEqualTo []) then { [CRPC(bank,responseSyncBank), [_bankPatch], _player] call CFUNC(targetEvent); }; + + private _orgPatch = _payment getOrDefault ["orgPatch", createHashMap]; + private _orgFleetPatch = _orgFleetResult getOrDefault ["patch", createHashMap]; + if (keys _orgFleetPatch isNotEqualTo []) then { { _orgPatch set [_x, _y]; } forEach _orgFleetPatch; }; + if (keys _orgPatch isNotEqualTo []) then { + private _orgTargetUids = _payment getOrDefault ["orgTargetUids", []]; + { + if !(_x in _orgTargetUids) then { _orgTargetUids pushBack _x; }; + } forEach (_orgFleetResult getOrDefault ["memberUids", []]); + + { + private _memberPlayer = [_x] call EFUNC(common,getPlayer); + if (_memberPlayer isNotEqualTo objNull) then { [CRPC(org,responseSyncOrg), [_orgPatch], _memberPlayer] call CFUNC(targetEvent); }; + } forEach _orgTargetUids; + }; + + _result set ["success", true]; + _result set ["message", format [ + "Checkout completed. %1 charged, %2 locker grant(s), %3 vehicle unlock(s).", + _self call ["formatCurrency", [_totalPrice]], + count (_lockerResult getOrDefault ["granted", []]), + count (_vgResult getOrDefault ["granted", []]) + ]]; + _result set ["lockerGranted", _lockerResult getOrDefault ["granted", []]]; + _result set ["vehicleGranted", _vgResult getOrDefault ["granted", []]]; + _result + }] +]; + +GVAR(StoreStore) = createHashMapObject [GVAR(StoreBaseStore)]; +GVAR(StoreStore) diff --git a/arma/server/addons/store/script_component.hpp b/arma/server/addons/store/script_component.hpp new file mode 100644 index 0000000..0b59e38 --- /dev/null +++ b/arma/server/addons/store/script_component.hpp @@ -0,0 +1,9 @@ +#define COMPONENT store +#define COMPONENT_BEAUTIFIED Store +#include "\forge\forge_server\addons\main\script_mod.hpp" + +// #define DEBUG_MODE_FULL +// #define DISABLE_COMPILE_CACHE +// #define ENABLE_PERFORMANCE_COUNTERS + +#include "\forge\forge_server\addons\main\script_macros.hpp" diff --git a/arma/server/addons/store/stringtable.xml b/arma/server/addons/store/stringtable.xml new file mode 100644 index 0000000..464a672 --- /dev/null +++ b/arma/server/addons/store/stringtable.xml @@ -0,0 +1,8 @@ + + + + + Store + + + diff --git a/arma/server/config.example.toml b/arma/server/config.example.toml index eddb170..cce0abb 100644 --- a/arma/server/config.example.toml +++ b/arma/server/config.example.toml @@ -16,6 +16,9 @@ db = 0 # Redis database number (0-15) max_connections = 10 # Maximum number of connections in pool min_connections = 2 # Minimum number of idle connections idle_timeout = 60 # Idle connection timeout in seconds +connect_timeout_ms = 2000 # Pool connect timeout in milliseconds +pool_get_timeout_ms = 2000 # Pool checkout timeout in milliseconds +command_timeout_ms = 2000 # Redis command timeout in milliseconds # Example configurations for different environments: @@ -33,3 +36,6 @@ idle_timeout = 60 # Idle connection timeout in seconds # max_connections = 20 # min_connections = 5 # idle_timeout = 30 +# connect_timeout_ms = 5000 +# pool_get_timeout_ms = 5000 +# command_timeout_ms = 5000 diff --git a/arma/server/docs/README.md b/arma/server/docs/README.md index 5aea712..350a651 100644 --- a/arma/server/docs/README.md +++ b/arma/server/docs/README.md @@ -5,6 +5,7 @@ A high-performance arma-rs extension for Arma 3, featuring a **low-level Redis d ## 🎯 Overview The Forge Server Redis module is designed as a **foundational data access layer** that: + - **Returns raw Redis responses** for maximum performance and flexibility - **Serves as the foundation** for higher-level game modules (actor, garage, locker, bank, etc.) - **Provides connection pooling** and error handling for Redis operations @@ -17,7 +18,7 @@ The Forge Server Redis module is designed as a **foundational data access layer* SQF Scripts ↓ (JSON responses) Game Modules (actor, garage, locker, bank) - ↓ (raw Redis responses) + ↓ (raw Redis responses) Redis Client Module ↓ (Redis protocol) Redis Server @@ -43,6 +44,7 @@ forge_server_x64.dll Extension ``` ### Key Components + - **lib.rs**: Manages global Redis pool and single Tokio runtime - **macros.rs**: Provides `redis_operation!` macro to eliminate boilerplate - **Operation modules**: Focus purely on Redis logic using the macro @@ -51,12 +53,14 @@ forge_server_x64.dll Extension ## 🚀 Features ### Raw Redis Operations + - **String Operations**: SET, GET, INCR, DECR, DEL, KEYS - **Hash Operations**: HSET, HGET, HMSET, HGETALL, HDEL, HKEYS, HVALS, HLEN - **List Operations**: LSET, LGET, LLEN, LRANGE, LPUSH, RPUSH, LPOP, RPOP, LTRIM, LREM - **Set Operations**: SADD, SMEMBERS, SCARD, SREM, SISMEMBER, SPOP, SRANDMEMBER ### Performance Features + - **Connection Pooling**: bb8-redis pool with configurable size and timeouts - **Single Runtime**: One shared Tokio runtime for all async operations - **Macro-Based**: `redis_operation!` macro eliminates boilerplate while maintaining performance @@ -87,11 +91,13 @@ port = 6379 ### Fallback Behavior The extension uses a robust fallback system: + 1. **Loads `config.toml`** if present in the extension directory 2. **Falls back to defaults** if configuration fails or file is missing 3. **Only fails** if both config and defaults cannot establish connection **Default Settings:** + - **Host**: `127.0.0.1` - **Port**: `6379` - **Max Connections**: `10` @@ -101,6 +107,7 @@ The extension uses a robust fallback system: ### Common Configurations **Development (Local Redis)**: + ```toml [redis] host = "127.0.0.1" @@ -110,6 +117,7 @@ min_connections = 1 ``` **Production (Remote Redis with Authentication)**: + ```toml [redis] host = "redis.example.com" @@ -124,20 +132,24 @@ idle_timeout = 60 ### Troubleshooting **Connection Issues:** + - Verify Redis server is running: `redis-cli ping` - Check host/port settings in `config.toml` - Ensure firewall allows connection **Authentication Issues:** + - Verify username/password in config - Check Redis server auth settings **Config File Issues:** + - Check TOML syntax with online validators - Ensure quotes are properly closed - Verify file permissions **Connection Pool Benefits:** + - Pre-warmed connections for zero-latency operations - Automatic connection recovery on network issues - Resource-efficient connection sharing @@ -146,23 +158,24 @@ idle_timeout = 60 ## 🔧 Installation 1. **Prerequisites**: - - Redis server (local or remote) - - Arma 3 server with extension support + - Redis server (local or remote) + - Arma 3 server with extension support 2. **Extension Setup**: - - Build the extension: `cargo build --release` - - Copy the compiled `forge_server_x64.dll` to your Arma 3 server - - Copy `config.example.toml` to `config.toml` and configure as needed - - Load in server config or mission + - Build the extension: `cargo build --release` + - Copy the compiled `forge_server_x64.dll` to your Arma 3 server + - Copy `config.example.toml` to `config.toml` and configure as needed + - Load in server config or mission 3. **Redis Server**: - ```bash - # Start Redis server - redis-server - - # Verify connection - redis-cli ping - ``` + + ```bash + # Start Redis server + redis-server + + # Verify connection + redis-cli ping + ``` ## 📝 Documentation @@ -184,6 +197,7 @@ idle_timeout = 60 This module returns **raw Redis responses** as strings for maximum performance: ### Success Responses + - **String values**: `"John"` (raw string) - **Numbers**: `"42"` (number as string) - **Lists/Arrays**: `"item1,item2,item3"` (comma-separated) @@ -192,12 +206,14 @@ This module returns **raw Redis responses** as strings for maximum performance: - **Status**: `"OK"` (for successful SET operations) ### Error Responses + - **Format**: `"Error: "` - **Pool errors**: `"Error: Redis pool not initialized"` - **Connection errors**: `"Error: "` - **Redis errors**: `"Error: "` ### Higher-Level JSON Formatting + Game modules (actor, garage, etc.) will wrap these raw responses in structured JSON for SQF consumption. ## ⚙️ Macro-Based Implementation @@ -220,7 +236,7 @@ pub fn set_key(key: String, value: String) -> String { ### What the Macro Handles - **Pool Management**: Retrieves Redis connection pool -- **Error Handling**: Returns "Error: ..." for pool/connection failures +- **Error Handling**: Returns "Error: ..." for pool/connection failures - **Async Bridging**: Uses shared Tokio runtime via `block_on()` - **Connection Acquisition**: Gets connection from pool with error handling - **Cleanup**: Automatic connection return to pool @@ -238,19 +254,19 @@ pub fn set_key(key: String, value: String) -> String { - **Language**: Rust (Edition 2024) - **Dependencies**: arma-rs, bb8-redis, redis, tokio - **Architecture**: Macro-based design with single runtime and connection pool -- **Key Patterns**: - - Global state management in `lib.rs` - - Boilerplate elimination via `redis_operation!` macro - - Synchronous interfaces over async operations - - Raw Redis responses for minimal overhead +- **Key Patterns**: + - Global state management in `lib.rs` + - Boilerplate elimination via `redis_operation!` macro + - Synchronous interfaces over async operations + - Raw Redis responses for minimal overhead - **Testing**: Unit tests for core functionality - ## 🚨 Error Handling The extension provides comprehensive error handling: + - Connection failures -- Redis operation errors +- Redis operation errors - Invalid parameters - Pool initialization errors @@ -259,10 +275,11 @@ All errors include descriptive messages for debugging. ## 🔍 Monitoring Connection pool status and Redis operations can be monitored through: + - Extension logs -- Redis server logs +- Redis server logs - Connection pool metrics --- -**Built with ❤️ for the Arma 3 community** \ No newline at end of file +**Built with ❤️ for the Arma 3 community** diff --git a/arma/server/docs/api-reference.md b/arma/server/docs/api-reference.md index a039827..47a3f05 100644 --- a/arma/server/docs/api-reference.md +++ b/arma/server/docs/api-reference.md @@ -7,6 +7,7 @@ Complete reference for **raw Redis operations** available in the Forge Server ex ## 🏗️ Implementation All Redis operations are implemented using the `redis_operation!` macro for: + - **Consistent Error Handling**: All functions return identical error formats - **Connection Management**: Automatic pool and connection handling - **Synchronous Interface**: Functions block until Redis operations complete @@ -19,6 +20,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re ## 📝 Common Operations ### SET - Store a key-value pair + **Command**: `redis:common:set` **Parameters**: `[key, value]` @@ -29,6 +31,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"OK"` ### GET - Retrieve a value by key + **Command**: `redis:common:get` **Parameters**: `[key]` @@ -39,6 +42,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"John"` (the actual stored value) ### INCR - Increment a numeric value + **Command**: `redis:common:incr` **Parameters**: `[key, increment_amount]` @@ -49,6 +53,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"110"` (the new value as string) ### DECR - Decrement a numeric value + **Command**: `redis:common:decr` **Parameters**: `[key, decrement_amount]` @@ -59,6 +64,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"2"` (the new value as string) ### DEL - Delete a key + **Command**: `redis:common:del` **Parameters**: `[key]` @@ -69,8 +75,9 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"1"` (number of keys deleted) ### KEYS - List all keys matching pattern + **Command**: `redis:common:keys` -**Parameters**: `[]` (currently returns all keys with "*" pattern) +**Parameters**: `[]` (currently returns all keys with "\*" pattern) ```sqf "forge_server" callExtension ["redis:common:keys", []] @@ -81,6 +88,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re ## 🗂️ Hash Operations ### HSET - Set hash field + **Command**: `redis:hash:set` **Parameters**: `[hash_key, field, value]` @@ -91,6 +99,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"1"` (number of fields added) ### HGET - Get hash field + **Command**: `redis:hash:get` **Parameters**: `[hash_key, field]` @@ -101,6 +110,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"John"` (the field value) ### HMSET - Set multiple hash fields + **Command**: `redis:hash:mset` **Parameters**: `[hash_key, [[field1, value1], [field2, value2], ...]]` @@ -111,6 +121,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"OK"` ### HGETALL - Get all hash fields + **Command**: `redis:hash:getall` **Parameters**: `[hash_key]` @@ -121,6 +132,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"name,John,score,100,level,5"` (comma-separated key-value pairs) ### HDEL - Delete hash field + **Command**: `redis:hash:del` **Parameters**: `[hash_key, field]` @@ -131,6 +143,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"1"` (number of fields removed) ### HKEYS - Get all hash field names + **Command**: `redis:hash:keys` **Parameters**: `[hash_key]` @@ -141,6 +154,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"name,score,level"` (comma-separated field names) ### HVALS - Get all hash values + **Command**: `redis:hash:vals` **Parameters**: `[hash_key]` @@ -151,6 +165,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"John,100,5"` (comma-separated values) ### HLEN - Get hash field count + **Command**: `redis:hash:len` **Parameters**: `[hash_key]` @@ -163,6 +178,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re ## 📋 List Operations ### LSET - Set list element by index + **Command**: `redis:list:set` **Parameters**: `[list_key, index, value]` @@ -173,6 +189,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"OK"` ### LGET - Get list element by index + **Command**: `redis:list:get` **Parameters**: `[list_key, index]` @@ -183,6 +200,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"patrol_alpha"` (the element value) ### LLEN - Get list length + **Command**: `redis:list:len` **Parameters**: `[list_key]` @@ -193,6 +211,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"5"` (list length) ### LRANGE - Get list elements in range + **Command**: `redis:list:range` **Parameters**: `[list_key, start_index, end_index]` @@ -203,6 +222,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"patrol_alpha,escort_beta,defend_gamma"` (comma-separated values) ### LPUSH - Add element to list head + **Command**: `redis:list:lpush` **Parameters**: `[list_key, value]` @@ -213,6 +233,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"6"` (new list length) ### RPUSH - Add element to list tail + **Command**: `redis:list:rpush` **Parameters**: `[list_key, value]` @@ -223,6 +244,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"6"` (new list length) ### LPOP - Remove and return element from list head + **Command**: `redis:list:lpop` **Parameters**: `[list_key, count]` @@ -233,6 +255,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"player_joined"` (removed element) or `"item1,item2"` (if count > 1) ### RPOP - Remove and return element from list tail + **Command**: `redis:list:rpop` **Parameters**: `[list_key, count]` @@ -243,6 +266,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"new_objective"` (removed element) or `"item1,item2"` (if count > 1) ### LTRIM - Trim list to specified range + **Command**: `redis:list:trim` **Parameters**: `[list_key, start_index, end_index]` @@ -253,6 +277,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"OK"` ### LREM - Remove elements from list + **Command**: `redis:list:del` **Parameters**: `[list_key, count, value]` @@ -265,6 +290,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re ## 🎯 Set Operations ### SADD - Add element to set + **Command**: `redis:set:add` **Parameters**: `[set_key, value]` @@ -275,6 +301,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"1"` (1 if element was added, 0 if already existed) ### SMEMBERS - Get all set members + **Command**: `redis:set:members` **Parameters**: `[set_key]` @@ -285,6 +312,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"player_123,player_456,player_789"` (comma-separated members) ### SCARD - Get set size + **Command**: `redis:set:card` **Parameters**: `[set_key]` @@ -295,6 +323,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"3"` (number of elements in set) ### SREM - Remove element from set + **Command**: `redis:set:del` **Parameters**: `[set_key, value]` @@ -305,6 +334,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"1"` (1 if element was removed, 0 if didn't exist) ### SISMEMBER - Check if element is in set + **Command**: `redis:set:ismember` **Parameters**: `[set_key, value]` @@ -315,6 +345,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"1"` (1 if member exists, 0 if not) ### SPOP - Remove and return random element + **Command**: `redis:set:pop` **Parameters**: `[set_key]` @@ -325,6 +356,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"mission_alpha"` (the removed element) ### SRANDMEMBER - Get random element without removing + **Command**: `redis:set:randmember` **Parameters**: `[set_key]` @@ -335,6 +367,7 @@ All redis client commands follow the pattern: `"forge_server" callExtension ["re **Raw Response**: `"mission_beta"` (a random element) ### SRANDMEMBER - Get multiple random elements + **Command**: `redis:set:randmembers` **Parameters**: `[set_key, count]` @@ -351,6 +384,7 @@ All commands may return error responses in this format: **Raw Error Response**: `"Error: "` ### Common Error Types + - **Pool not initialized**: `"Error: Redis pool not initialized"` - **Connection failed**: `"Error: Connection refused (os error 61)"` - **Key not found**: `"Error: key not found"` (for operations on non-existent keys) @@ -358,16 +392,18 @@ All commands may return error responses in this format: - **Index out of range**: `"Error: index out of range"` (for list operations) ### Error Handling in Game Modules + Higher-level game modules should check if the response starts with `"Error: "` to distinguish between successful responses and errors. ```json { - "status": "error", - "error": "Failed to connect to Redis server" + "status": "error", + "error": "Failed to connect to Redis server" } ``` Common error types: + - **Connection errors**: Redis server unavailable - **Operation errors**: Invalid data type for operation - **Parameter errors**: Missing or invalid parameters @@ -376,11 +412,13 @@ Common error types: ## 📊 Response Fields ### Common Fields + - `status`: Always present - "success" or "error" - `key`: The Redis key being operated on - `error`: Error message (only on error responses) ### Success-Specific Fields + - `data`: The retrieved data (for GET operations) - `value`: The stored value (for SET operations) - `was_new`: Boolean indicating if operation created new data diff --git a/arma/server/docs/usage-examples.md b/arma/server/docs/usage-examples.md index 24515e2..026450c 100644 --- a/arma/server/docs/usage-examples.md +++ b/arma/server/docs/usage-examples.md @@ -7,6 +7,7 @@ Practical examples of using the **raw Redis client module** as a foundation for ## 🚀 Function Behavior All Redis functions are **synchronous from SQF's perspective**: + - Functions **block** until Redis operation completes - **No callbacks** or async handling needed in SQF - **Direct return values** – either data or error strings @@ -17,6 +18,7 @@ The extension handles all async complexity internally using a macro-based archit ## 🎮 Player Management ### Player Join/Leave Tracking + ```sqf // When player joins _playerUID = getPlayerUID player; @@ -35,15 +37,16 @@ _playerName = name player; ``` ### Player Statistics System + ```sqf // Initialize player stats fnc_initPlayerStats = { params ["_playerUID"]; - + _playerKey = format ["stats:%1", _playerUID]; "forge_server" callExtension ["redis:hash:mset", [_playerKey, [ ["kills", "0"], - ["deaths", "0"], + ["deaths", "0"], ["score", "0"], ["playtime", "0"] ]]]; @@ -52,7 +55,7 @@ fnc_initPlayerStats = { // Update player kill fnc_addPlayerKill = { params ["_playerUID"]; - + _playerKey = format ["stats:%1", _playerUID]; "forge_server" callExtension ["redis:hash:incr", [_playerKey, "kills", 1]]; "forge_server" callExtension ["redis:hash:incr", [_playerKey, "score", 10]]; @@ -61,11 +64,11 @@ fnc_addPlayerKill = { // Get player stats (raw response) fnc_getPlayerStats = { params ["_playerUID"]; - + _playerKey = format ["stats:%1", _playerUID]; _rawResult = "forge_server" callExtension ["redis:hash:getall", [_playerKey]]; // _rawResult is now "kills,15,deaths,3,score,150,playtime,7200" - + // Game modules would parse this into structured data // For now, return raw comma-separated response _rawResult select 0; @@ -75,14 +78,15 @@ fnc_getPlayerStats = { ## 🏆 Leaderboards and Rankings ### Global Kill Leaderboard + ```sqf // Add score to sorted leaderboard (using list for simplicity) fnc_updateLeaderboard = { params ["_playerName", "_kills"]; - + // Store individual score "forge_server" callExtension ["redis:common:set", [format ["kills:%1", _playerName], str _kills]]; - + // Add to leaderboard tracking "forge_server" callExtension ["redis:set:add", ["leaderboard_players", _playerName]]; }; @@ -92,29 +96,29 @@ fnc_getTopPlayers = { // Get all leaderboard players - returns comma-separated list _playersResult = "forge_server" callExtension ["redis:set:members", ["leaderboard_players"]]; _rawPlayers = _playersResult select 0; - + // Check for error if (_rawPlayers find "Error:" == 0) exitWith { [] }; - + // Split comma-separated player list _players = _rawPlayers splitString ","; _scoreArray = []; - + // Get scores for all players { _killsResult = "forge_server" callExtension ["redis:common:get", [format ["kills:%1", _x]]]; _rawKills = _killsResult select 0; - + // Check for valid response (not an error) if (_rawKills find "Error:" != 0) then { _scoreArray pushBack [_x, parseNumber _rawKills]; }; } forEach _players; - + // Sort by score (highest first) _scoreArray sort false; _scoreArray resize (10 min (count _scoreArray)); // Top 10 - + _scoreArray; }; ``` @@ -122,13 +126,14 @@ fnc_getTopPlayers = { ## 🎯 Mission State Management ### Objective System + ```sqf // Set mission objectives fnc_initMissionObjectives = { "forge_server" callExtension ["redis:list:rpush", ["objectives", "Secure Alpha Base"]]; "forge_server" callExtension ["redis:list:rpush", ["objectives", "Extract Intel"]]; "forge_server" callExtension ["redis:list:rpush", ["objectives", "Eliminate HVT"]]; - + // Set current objective pointer "forge_server" callExtension ["redis:common:set", ["current_objective", "0"]]; }; @@ -138,24 +143,24 @@ fnc_completeObjective = { // Get current objective index - returns raw string _indexResult = "forge_server" callExtension ["redis:common:get", ["current_objective"]]; _rawIndex = _indexResult select 0; - + // Check for error if (_rawIndex find "Error:" == 0) exitWith {}; - + _currentIndex = parseNumber _rawIndex; - + // Get objective name - returns raw string _objResult = "forge_server" callExtension ["redis:list:get", ["objectives", _currentIndex]]; _objectiveName = _objResult select 0; - + // Check for valid response if (_objectiveName find "Error:" != 0) then { // Move to completed objectives - returns new list length "forge_server" callExtension ["redis:list:rpush", ["completed_objectives", _objectiveName]]; - + // Move to next objective - returns "OK" "forge_server" callExtension ["redis:common:set", ["current_objective", str (_currentIndex + 1)]]; - + // Broadcast completion [format ["Objective Complete: %1", _objectiveName]] remoteExec ["hint"]; }; @@ -165,18 +170,18 @@ fnc_completeObjective = { fnc_getMissionProgress = { _totalResult = "forge_server" callExtension ["redis:list:len", ["objectives"]]; _completedResult = "forge_server" callExtension ["redis:list:len", ["completed_objectives"]]; - + _rawTotal = _totalResult select 0; _rawCompleted = _completedResult select 0; - + // Check for errors if (_rawTotal find "Error:" == 0 || _rawCompleted find "Error:" == 0) exitWith { "Mission Progress: Unknown"; }; - + _total = parseNumber _rawTotal; _completed = parseNumber _rawCompleted; - + format ["Mission Progress: %1/%2 objectives completed", _completed, _total]; }; ``` @@ -184,11 +189,12 @@ fnc_getMissionProgress = { ## 🚁 Vehicle and Equipment Tracking ### Vehicle Pool System + ```sqf // Initialize vehicle pool fnc_initVehiclePool = { params ["_vehicleClass", "_count"]; - + for "_i" from 1 to _count do { _vehicleId = format ["%1_%2", _vehicleClass, _i]; "forge_server" callExtension ["redis:set:add", ["available_vehicles", _vehicleId]]; @@ -203,19 +209,19 @@ fnc_initVehiclePool = { // Request vehicle fnc_requestVehicle = { params ["_playerUID"]; - + // Get random available vehicle _result = "forge_server" callExtension ["redis:set:pop", ["available_vehicles"]]; _data = fromJSON (_result select 0); - + if ((_data select "status") == "success") then { _vehicleId = _data select "data"; - + // Mark as in use "forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "status", "in_use"]]; "forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "user", _playerUID]]; "forge_server" callExtension ["redis:set:add", ["used_vehicles", _vehicleId]]; - + _vehicleId; } else { ""; // No vehicles available @@ -225,10 +231,10 @@ fnc_requestVehicle = { // Return vehicle fnc_returnVehicle = { params ["_vehicleId", "_condition"]; - + // Update condition "forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "condition", str _condition]]; - + // Return to pool if condition is good if (_condition > 50) then { "forge_server" callExtension ["redis:hash:set", [format ["vehicle:%1", _vehicleId], "status", "available"]]; @@ -245,39 +251,40 @@ fnc_returnVehicle = { ## 📊 Server Analytics ### Player Session Tracking + ```sqf // Track player session start fnc_startPlayerSession = { params ["_playerUID"]; - + _sessionId = format ["%1_%2", _playerUID, floor time]; _sessionKey = format ["session:%1", _sessionId]; - + "forge_server" callExtension ["redis:hash:mset", [_sessionKey, [ ["player_uid", _playerUID], ["start_time", str time], ["server_id", serverName], ["player_count", str (count allPlayers)] ]]]; - + // Store current session for player "forge_server" callExtension ["redis:common:set", [format ["current_session:%1", _playerUID], _sessionId]]; - + _sessionId; }; // End player session fnc_endPlayerSession = { params ["_playerUID", "_sessionStats"]; - + // Get current session _result = "forge_server" callExtension ["redis:common:get", [format ["current_session:%1", _playerUID]]]; _data = fromJSON (_result select 0); - + if ((_data select "status") == "success") then { _sessionId = _data select "data"; _sessionKey = format ["session:%1", _sessionId]; - + // Update session with end data "forge_server" callExtension ["redis:hash:mset", [_sessionKey, [ ["end_time", str time], @@ -285,7 +292,7 @@ fnc_endPlayerSession = { ["kills", str (_sessionStats select "kills")], ["deaths", str (_sessionStats select "deaths")] ]]]; - + // Clean up current session tracking "forge_server" callExtension ["redis:common:del", [format ["current_session:%1", _playerUID]]]; }; @@ -295,17 +302,18 @@ fnc_endPlayerSession = { ## 🔄 Cross-Server Communication ### Message Queue System + ```sqf // Send message to other servers fnc_sendCrossServerMessage = { params ["_targetServer", "_messageType", "_messageData"]; - + _message = createHashMap; _message set ["from_server", serverName]; _message set ["type", _messageType]; _message set ["data", _messageData]; _message set ["timestamp", str time]; - + _queueKey = format ["messages:%1", _targetServer]; "forge_server" callExtension ["redis:list:rpush", [_queueKey, str _message]]; }; @@ -313,21 +321,21 @@ fnc_sendCrossServerMessage = { // Check for incoming messages fnc_checkMessages = { _queueKey = format ["messages:%1", serverName]; - + // Get next message _result = "forge_server" callExtension ["redis:list:lpop", [_queueKey, 1]]; _data = fromJSON (_result select 0); - + if ((_data select "status") == "success") then { _messages = _data select "data"; if (count _messages > 0) then { _messageStr = _messages select 0; _message = fromJSON _messageStr; - + // Process message based on type _type = _message select "type"; _messageData = _message select "data"; - + switch (_type) do { case "player_transfer": { [_messageData] call fnc_handlePlayerTransfer; @@ -355,11 +363,12 @@ fnc_checkMessages = { ## 🛠️ Utility Functions ### Redis Helper Functions + ```sqf // Parse Redis response safely fnc_parseRedisResponse = { params ["_response"]; - + try { _data = fromJSON (_response select 0); if ((_data select "status") == "success") then { @@ -377,14 +386,14 @@ fnc_parseRedisResponse = { // Batch Redis operations fnc_redisBatch = { params ["_operations"]; - + _results = []; { _op = _x; _result = "forge_server" callExtension [_op select 0, _op select 1]; _results pushBack (fromJSON (_result select 0)); } forEach _operations; - + _results; }; @@ -400,14 +409,15 @@ _results = [_batchOps] call fnc_redisBatch; ## 🎯 Best Practices ### Error Handling Pattern + ```sqf fnc_safeRedisCall = { params ["_command", "_params", ["_defaultValue", nil]]; - + try { _result = "forge_server" callExtension [_command, _params]; _data = fromJSON (_result select 0); - + if ((_data select "status") == "success") then { _data select "data"; } else { @@ -424,4 +434,4 @@ fnc_safeRedisCall = { _playerName = ["redis:common:get", ["player_name"], "Unknown"] call fnc_safeRedisCall; ``` -These examples demonstrate real-world usage patterns for the Redis extension in Arma 3 environments, covering player management, mission state, analytics, and cross-server communication. \ No newline at end of file +These examples demonstrate real-world usage patterns for the Redis extension in Arma 3 environments, covering player management, mission state, analytics, and cross-server communication. diff --git a/arma/server/extension/README.md b/arma/server/extension/README.md index f1c535a..7f118a4 100644 --- a/arma/server/extension/README.md +++ b/arma/server/extension/README.md @@ -26,16 +26,16 @@ The Organization module handles guild/clan management, allowing players to form ### Available Commands -| Command | Description | -|---------|-------------| -| `org:get` | Retrieve organization data by key or ID. | -| `org:create` | Create a new organization with provided JSON data. | -| `org:update` | Update an existing organization with partial JSON data. | -| `org:delete` | Permanently remove an organization and its data. | -| `org:exists` | Check if an organization exists. | -| `org:get_members` | Retrieve a list of organization members. | -| `org:add_member` | Add a member to an organization. | -| `org:remove_member` | Remove a member from an organization. | +| Command | Description | +| ------------------- | ------------------------------------------------------- | +| `org:get` | Retrieve organization data by key or ID. | +| `org:create` | Create a new organization with provided JSON data. | +| `org:update` | Update an existing organization with partial JSON data. | +| `org:delete` | Permanently remove an organization and its data. | +| `org:exists` | Check if an organization exists. | +| `org:get_members` | Retrieve a list of organization members. | +| `org:add_member` | Add a member to an organization. | +| `org:remove_member` | Remove a member from an organization. | ### SQF Examples @@ -128,13 +128,13 @@ The Actor module handles all player-related operations, including data retrieval ### Available Commands -| Command | Description | -|---------|-------------| -| `actor:get` | Retrieve actor data by key or UID. | -| `actor:create` | Create a new actor with provided JSON data. | +| Command | Description | +| -------------- | ------------------------------------------------ | +| `actor:get` | Retrieve actor data by key or UID. | +| `actor:create` | Create a new actor with provided JSON data. | | `actor:update` | Update an existing actor with partial JSON data. | -| `actor:exists` | Check if an actor exists in the database. | -| `actor:delete` | Permanently remove an actor and their data. | +| `actor:exists` | Check if an actor exists in the database. | +| `actor:delete` | Permanently remove an actor and their data. | ### SQF Examples @@ -249,6 +249,7 @@ We welcome contributions to the Forge Extension! This guide will help you unders To add a new command to an existing module (e.g., `actor:set_position`), follow these steps: 1. **Register the Command**: In the module file (e.g., `src/actor.rs`), add the command to the `group()` function. + ```rust pub fn group() -> Group { Group::new() @@ -262,6 +263,7 @@ To add a new command to an existing module (e.g., `actor:set_position`), follow ``` 2. **Implement the Handler Function**: Create the function that handles the command logic. + ```rust use crate::log::log; @@ -293,7 +295,7 @@ To add a new command to an existing module (e.g., `actor:set_position`), follow match ACTOR_SERVICE.get_actor(resolved_uid.clone()) { Ok(mut actor) => { actor.set_position(position_data); - + match ACTOR_SERVICE.update_actor(actor.clone()) { Ok(_) => { log("actor", "INFO", &format!("Updated position for: {}", resolved_uid)); @@ -316,6 +318,7 @@ To create a new module (e.g., `vehicle`), follow these steps: 1. **Create the Module File**: Add `src/vehicle.rs`. 2. **Create the Global Service Instance**: Define a lazily initialized singleton service. + ```rust use std::sync::LazyLock; use forge_services::VehicleService; @@ -329,6 +332,7 @@ To create a new module (e.g., `vehicle`), follow these steps: VehicleService::new(repository) }); ``` + 3. **Register the Command**: In the module file, register the command in the `group()` function. ```rust pub fn group() -> Group { @@ -339,6 +343,7 @@ To create a new module (e.g., `vehicle`), follow these steps: } ``` 4. **Use Logging**: Import and use the generic `log` function in your handler functions. + ```rust use crate::log::log; @@ -369,7 +374,7 @@ To create a new module (e.g., `vehicle`), follow these steps: } } ``` - + The `log` function takes three parameters: - `category`: The log category (e.g., "vehicle", "actor", "org") - `level`: The log level ("INFO", "DEBUG", "WARN", "ERROR") @@ -378,9 +383,10 @@ To create a new module (e.g., `vehicle`), follow these steps: Log files are created automatically in `@forge_server/logs/{category}.log`. 5. **Register the Module** (if new): If you created a new module, add it to `src/lib.rs`. + ```rust pub mod vehicle; - + // In the extension function, register the group extension.group("vehicle", vehicle::group()); ``` diff --git a/arma/server/extension/config.example.toml b/arma/server/extension/config.example.toml index 2f0e061..f556f92 100644 --- a/arma/server/extension/config.example.toml +++ b/arma/server/extension/config.example.toml @@ -16,6 +16,9 @@ db = 0 # Redis database number (0-15) max_connections = 10 # Maximum number of connections in pool min_connections = 2 # Minimum number of idle connections idle_timeout = 60 # Idle connection timeout in seconds +connect_timeout_ms = 2000 # Pool connect timeout in milliseconds +pool_get_timeout_ms = 2000 # Pool checkout timeout in milliseconds +command_timeout_ms = 2000 # Redis command timeout in milliseconds # Example configurations for different environments: @@ -33,3 +36,6 @@ idle_timeout = 60 # Idle connection timeout in seconds # max_connections = 20 # min_connections = 5 # idle_timeout = 30 +# connect_timeout_ms = 5000 +# pool_get_timeout_ms = 5000 +# command_timeout_ms = 5000 diff --git a/arma/server/extension/src/actor.rs b/arma/server/extension/src/actor.rs index 6bbcd5f..f11f103 100644 --- a/arma/server/extension/src/actor.rs +++ b/arma/server/extension/src/actor.rs @@ -241,13 +241,12 @@ pub fn actor_exists(call_context: CallContext, key: String) -> String { match ACTOR_SERVICE.actor_exists(resolved_uid.clone()) { Ok(exists) => { - let result = if exists { "true" } else { "false" }; log( "actor", "DEBUG", - &format!("Actor '{}' exists: {}", resolved_uid, result), + &format!("Actor '{}' exists: {}", resolved_uid, exists), ); - result.to_string() + exists.to_string() } Err(e) => { log( diff --git a/arma/server/extension/src/adapters/README.md b/arma/server/extension/src/adapters/README.md index 311a2f4..167b2bd 100644 --- a/arma/server/extension/src/adapters/README.md +++ b/arma/server/extension/src/adapters/README.md @@ -19,6 +19,7 @@ graph TD ``` This design enables: + - **Testability**: Repositories can use mock adapters for testing - **Flexibility**: Different Redis implementations can be swapped without changing repositories - **Separation of Concerns**: Repository logic is independent of Redis connection details @@ -38,35 +39,35 @@ The `ExtensionRedisClient` is the primary adapter that implements the `RedisClie #### Hash Operations -| Method | Description | Returns | -|--------|-------------|---------| -| `hash_mset` | Set multiple fields atomically | `Result<(), String>` | -| `hash_get_all` | Get all fields and values | `Result` | -| `hash_get` | Get a single field value | `Result` | -| `hash_del` | Delete a field | `Result<(), String>` | +| Method | Description | Returns | +| -------------- | ------------------------------ | ------------------------ | +| `hash_mset` | Set multiple fields atomically | `Result<(), String>` | +| `hash_get_all` | Get all fields and values | `Result` | +| `hash_get` | Get a single field value | `Result` | +| `hash_del` | Delete a field | `Result<(), String>` | #### List Operations -| Method | Description | Returns | -|--------|-------------|---------| -| `list_rpush` | Append to list | `Result<(), String>` | +| Method | Description | Returns | +| ------------ | --------------------- | ----------------------------- | +| `list_rpush` | Append to list | `Result<(), String>` | | `list_range` | Get range of elements | `Result, String>` | -| `list_del` | Remove by value | `Result<(), String>` | +| `list_del` | Remove by value | `Result<(), String>` | #### Set Operations -| Method | Description | Returns | -|--------|-------------|---------| -| `set_add` | Add member | `Result<(), String>` | +| Method | Description | Returns | +| ------------- | --------------- | ----------------------------- | +| `set_add` | Add member | `Result<(), String>` | | `set_members` | Get all members | `Result, String>` | -| `set_del` | Remove member | `Result<(), String>` | +| `set_del` | Remove member | `Result<(), String>` | #### Common Operations -| Method | Description | Returns | -|--------|-------------|---------| +| Method | Description | Returns | +| ------------ | ------------------- | ---------------------- | | `key_exists` | Check if key exists | `Result` | -| `delete_key` | Delete key | `Result<(), String>` | +| `delete_key` | Delete key | `Result<(), String>` | ### Usage Example @@ -119,6 +120,7 @@ We welcome contributions to the adapters module! Follow these guidelines to add To add a new method (e.g., `hash_exists`), follow these steps: 1. **Check the Trait**: Ensure the method is defined in the `RedisClient` trait in `forge_shared`. + ```rust // In forge_shared/src/redis_client.rs pub trait RedisClient: Send + Sync { @@ -127,6 +129,7 @@ To add a new method (e.g., `hash_exists`), follow these steps: ``` 2. **Implement the Method**: Add the implementation to `ExtensionRedisClient`. + ```rust impl RedisClient for ExtensionRedisClient { fn hash_exists(&self, key: String, field: String) -> Result { @@ -145,6 +148,7 @@ To add a new method (e.g., `hash_exists`), follow these steps: ``` 3. **Add Logging** (if needed): For debugging, log the operation. + ```rust fn hash_exists(&self, key: String, field: String) -> Result { let result = redis::hash::hash_exists(key, field); @@ -172,6 +176,7 @@ To create a new adapter (e.g., `MockRedisClient` for testing): 1. **Create the Module File**: Add `src/adapters/mock_client.rs`. 2. **Define the Struct**: Create the adapter struct. + ```rust use forge_shared::RedisClient; use std::collections::HashMap; @@ -194,6 +199,7 @@ To create a new adapter (e.g., `MockRedisClient` for testing): ``` 3. **Implement the Trait**: Implement all `RedisClient` methods. + ```rust impl RedisClient for MockRedisClient { fn hash_mset(&self, key: String, fields: Vec<(String, String)>) -> Result<(), String> { @@ -220,6 +226,7 @@ To create a new adapter (e.g., `MockRedisClient` for testing): ``` 4. **Register the Module**: Add to `src/adapters/mod.rs`. + ```rust pub mod redis_client; pub mod mock_client; @@ -235,18 +242,20 @@ To create a new adapter (e.g., `MockRedisClient` for testing): **Recommended Synchronization Primitives:** -| Primitive | Use Case | Performance | Dependency | -|-----------|----------|-------------|------------| +| Primitive | Use Case | Performance | Dependency | +| --------------------- | ---------------------------------------- | ----------------------- | ---------------- | | **`RwLock`** | Read-heavy workloads, concurrent readers | Good (multiple readers) | Standard library | -| **`Mutex`** | Write-heavy or exclusive access required | Fair (single lock) | Standard library | -| **`DashMap`** | Extreme high-frequency reads/writes | Excellent (lock-free) | External crate | +| **`Mutex`** | Write-heavy or exclusive access required | Fair (single lock) | Standard library | +| **`DashMap`** | Extreme high-frequency reads/writes | Excellent (lock-free) | External crate | **When to use each:** + - **`RwLock`**: Best for most use cases. Allows multiple concurrent readers, only blocks on writes. Use this by default. - **`Mutex`**: Only when you need exclusive access or operations are very lightweight (< 1μs). - **`DashMap`**: When profiling shows `RwLock` is a bottleneck and you need lock-free performance. **Why avoid `Mutex` for read-heavy workloads?** + - Blocks all threads (readers and writers) on every access - No concurrent reads possible - Can cause performance bottlenecks in high-concurrency scenarios diff --git a/arma/server/extension/src/bank.rs b/arma/server/extension/src/bank.rs index bac62a8..5e3d85e 100644 --- a/arma/server/extension/src/bank.rs +++ b/arma/server/extension/src/bank.rs @@ -222,11 +222,7 @@ pub fn bank_exists(call_context: CallContext, key: String) -> String { let resolved_uid = match resolve_uid(&key, &call_context) { Some(uid) => { - log( - "bank", - "DEBUG", - &format!("Resolved UID for existence check: {}", uid), - ); + log("bank", "DEBUG", &format!("Resolved UID: {}", uid)); uid } None => { @@ -241,13 +237,12 @@ pub fn bank_exists(call_context: CallContext, key: String) -> String { match BANK_SERVICE.bank_exists(resolved_uid.clone()) { Ok(exists) => { - let result = if exists { "true" } else { "false" }; log( "bank", "DEBUG", - &format!("Bank '{}' exists: {}", resolved_uid, result), + &format!("Bank '{}' exists: {}", resolved_uid, exists), ); - result.to_string() + exists.to_string() } Err(e) => { log( diff --git a/arma/server/extension/src/garage.rs b/arma/server/extension/src/garage.rs index 38690d0..c1488a2 100644 --- a/arma/server/extension/src/garage.rs +++ b/arma/server/extension/src/garage.rs @@ -518,9 +518,12 @@ pub fn garage_exists(call_context: CallContext, key: String) -> String { uid } None => { - let error_msg = format!("Error: Failed to resolve UID for key: {}", key); - log("garage", "ERROR", &error_msg); - return error_msg; + log( + "garage", + "ERROR", + &format!("Failed to resolve UID for key: {}", key), + ); + return "false".to_string(); } }; @@ -529,18 +532,17 @@ pub fn garage_exists(call_context: CallContext, key: String) -> String { log( "garage", "DEBUG", - &format!("Garage exists for '{}': {}", resolved_uid, exists), + &format!("Garage '{}' exists: {}", resolved_uid, exists), ); exists.to_string() } Err(e) => { - let error_msg = format!("Error: {}", e); log( "garage", "ERROR", - &format!("Failed to check garage existence '{}': {}", resolved_uid, e), + &format!("Failed to check if garage '{}' exists: {}", resolved_uid, e), ); - error_msg + "false".to_string() } } } diff --git a/arma/server/extension/src/lib.rs b/arma/server/extension/src/lib.rs index 1d71c7e..e5665bf 100644 --- a/arma/server/extension/src/lib.rs +++ b/arma/server/extension/src/lib.rs @@ -21,6 +21,7 @@ pub mod locker; mod log; pub mod org; pub mod redis; +pub mod terrain; pub mod v_garage; pub mod v_locker; @@ -66,6 +67,7 @@ fn init() -> Extension { .group("icom", icom::group()) .group("locker", locker::group()) .group("org", org::group()) + .group("terrain", terrain::group()) .group( "owned", Group::new() diff --git a/arma/server/extension/src/locker.rs b/arma/server/extension/src/locker.rs index 0f42ac5..c20b3c4 100644 --- a/arma/server/extension/src/locker.rs +++ b/arma/server/extension/src/locker.rs @@ -473,9 +473,12 @@ pub fn locker_exists(call_context: CallContext, key: String) -> String { uid } None => { - let error_msg = format!("Error: Failed to resolve UID for key: {}", key); - log("locker", "ERROR", &error_msg); - return error_msg; + log( + "locker", + "ERROR", + &format!("Failed to resolve UID for key: {}", key), + ); + return "false".to_string(); } }; @@ -483,13 +486,12 @@ pub fn locker_exists(call_context: CallContext, key: String) -> String { Ok(exists) => { log( "locker", - "INFO", - &format!("Locker exists for: {}", resolved_uid), + "DEBUG", + &format!("Locker '{}' exists: {}", resolved_uid, exists), ); exists.to_string() } Err(e) => { - let error_msg = format!("Error: {}", e); log( "locker", "ERROR", @@ -498,7 +500,7 @@ pub fn locker_exists(call_context: CallContext, key: String) -> String { resolved_uid, e ), ); - error_msg + "false".to_string() } } } diff --git a/arma/server/extension/src/redis/README.md b/arma/server/extension/src/redis/README.md index 7183ace..5fd4165 100644 --- a/arma/server/extension/src/redis/README.md +++ b/arma/server/extension/src/redis/README.md @@ -16,6 +16,7 @@ The Redis module is organized into specialized operation groups: ### Connection Pool The module uses `bb8` for connection pooling, providing: + - **Automatic connection reuse**: Reduces overhead - **Configurable pool size**: Control max/min connections - **Idle timeout**: Prevents stale connections @@ -41,14 +42,14 @@ Basic key-value operations for simple data storage. ### Available Commands -| Command | Description | Returns | -|---------|-------------|---------| -| `redis:common:set` | Set a string value | "OK" | -| `redis:common:get` | Get a string value | Value or empty string | -| `redis:common:incr` | Increment a numeric value | New value | -| `redis:common:decr` | Decrement a numeric value | New value | -| `redis:common:del` | Delete a key | Number of keys removed | -| `redis:common:keys` | List all keys | Comma-separated keys | +| Command | Description | Returns | +| ------------------- | ------------------------- | ---------------------- | +| `redis:common:set` | Set a string value | "OK" | +| `redis:common:get` | Get a string value | Value or empty string | +| `redis:common:incr` | Increment a numeric value | New value | +| `redis:common:decr` | Decrement a numeric value | New value | +| `redis:common:del` | Delete a key | Number of keys removed | +| `redis:common:keys` | List all keys | Comma-separated keys | ### SQF Examples @@ -73,17 +74,17 @@ Hash operations store structured data as field-value pairs, ideal for objects an ### Available Commands -| Command | Description | Returns | -|---------|-------------|---------| -| `redis:hash:set` | Set a single field | 1 if new, 0 if updated | -| `redis:hash:mset` | Set multiple fields atomically | "OK" | -| `redis:hash:get` | Get a field value | Value or empty string | -| `redis:hash:getall` | Get all fields and values | Comma-separated pairs | -| `redis:hash:del` | Delete a field | Number of fields removed | -| `redis:hash:keys` | Get all field names | Comma-separated keys | -| `redis:hash:vals` | Get all values | Comma-separated values | -| `redis:hash:len` | Get number of fields | Field count | -| `redis:hash:exists` | Check if field exists | "1" or "0" | +| Command | Description | Returns | +| ------------------- | ------------------------------ | ------------------------ | +| `redis:hash:set` | Set a single field | 1 if new, 0 if updated | +| `redis:hash:mset` | Set multiple fields atomically | "OK" | +| `redis:hash:get` | Get a field value | Value or empty string | +| `redis:hash:getall` | Get all fields and values | Comma-separated pairs | +| `redis:hash:del` | Delete a field | Number of fields removed | +| `redis:hash:keys` | Get all field names | Comma-separated keys | +| `redis:hash:vals` | Get all values | Comma-separated values | +| `redis:hash:len` | Get number of fields | Field count | +| `redis:hash:exists` | Check if field exists | "1" or "0" | ### SQF Examples @@ -118,18 +119,18 @@ List operations manage ordered collections, useful for queues, logs, and sequent ### Available Commands -| Command | Description | Returns | -|---------|-------------|---------| -| `redis:list:set` | Set element at index | "OK" | -| `redis:list:get` | Get element at index | Value (base64 decoded) | -| `redis:list:len` | Get list length | Element count | -| `redis:list:range` | Get range of elements | JSON array | -| `redis:list:lpush` | Prepend to list | New length | -| `redis:list:rpush` | Append to list | New length | -| `redis:list:lpop` | Remove from beginning | JSON array of removed elements | -| `redis:list:rpop` | Remove from end | JSON array of removed elements | -| `redis:list:trim` | Trim to range | "OK" | -| `redis:list:del` | Remove by value | Number removed | +| Command | Description | Returns | +| ------------------ | --------------------- | ------------------------------ | +| `redis:list:set` | Set element at index | "OK" | +| `redis:list:get` | Get element at index | Value (base64 decoded) | +| `redis:list:len` | Get list length | Element count | +| `redis:list:range` | Get range of elements | JSON array | +| `redis:list:lpush` | Prepend to list | New length | +| `redis:list:rpush` | Append to list | New length | +| `redis:list:lpop` | Remove from beginning | JSON array of removed elements | +| `redis:list:rpop` | Remove from end | JSON array of removed elements | +| `redis:list:trim` | Trim to range | "OK" | +| `redis:list:del` | Remove by value | Number removed | ### SQF Examples @@ -159,16 +160,16 @@ Set operations manage unique collections, perfect for membership tracking and pr ### Available Commands -| Command | Description | Returns | -|---------|-------------|---------| -| `redis:set:add` | Add member to set | 1 if new, 0 if exists | -| `redis:set:members` | Get all members | Comma-separated members | -| `redis:set:card` | Get member count | Cardinality | -| `redis:set:ismember` | Check membership | "1" or "0" | -| `redis:set:randmember` | Get random member | Member value | -| `redis:set:randmembers` | Get N random members | Comma-separated members | -| `redis:set:pop` | Remove random member | Removed member | -| `redis:set:del` | Remove specific member | 1 if removed, 0 if not found | +| Command | Description | Returns | +| ----------------------- | ---------------------- | ---------------------------- | +| `redis:set:add` | Add member to set | 1 if new, 0 if exists | +| `redis:set:members` | Get all members | Comma-separated members | +| `redis:set:card` | Get member count | Cardinality | +| `redis:set:ismember` | Check membership | "1" or "0" | +| `redis:set:randmember` | Get random member | Member value | +| `redis:set:randmembers` | Get N random members | Comma-separated members | +| `redis:set:pop` | Remove random member | Removed member | +| `redis:set:del` | Remove specific member | 1 if removed, 0 if not found | ### SQF Examples @@ -238,6 +239,7 @@ pub fn my_redis_command(key: String) -> String { ``` The macro automatically: + - Acquires a connection from the pool - Handles lazy initialization if needed - Executes the operation asynchronously @@ -246,6 +248,7 @@ The macro automatically: ## Error Handling All Redis operations return strings: + - **Success**: Operation result (e.g., "OK", value, count) - **Error**: String starting with "Error: " followed by the error message diff --git a/arma/server/extension/src/redis/client.rs b/arma/server/extension/src/redis/client.rs index 3067ac9..0ebc910 100644 --- a/arma/server/extension/src/redis/client.rs +++ b/arma/server/extension/src/redis/client.rs @@ -1,6 +1,7 @@ use super::config::RedisConfig; use bb8_redis::{RedisConnectionManager, bb8}; use std::error::Error; +use std::time::Duration; /// Redis connection pool type alias. pub type RedisClient = bb8::Pool; @@ -33,10 +34,14 @@ pub async fn create_redis_pool( // Configure idle connection timeout if specified // This prevents keeping stale connections that might be closed by the server if let Some(idle_timeout) = config.idle_timeout { - use std::time::Duration; pool_builder = pool_builder.idle_timeout(Some(Duration::from_secs(idle_timeout))); } + // Bound connection acquisition from the pool so game thread calls fail fast + if let Some(connect_timeout_ms) = config.connect_timeout_ms { + pool_builder = pool_builder.connection_timeout(Duration::from_millis(connect_timeout_ms)); + } + // Build the final connection pool with all configured parameters let pool = pool_builder.build(manager).await?; Ok(pool) diff --git a/arma/server/extension/src/redis/config.rs b/arma/server/extension/src/redis/config.rs index eaadfa5..0b3c9df 100644 --- a/arma/server/extension/src/redis/config.rs +++ b/arma/server/extension/src/redis/config.rs @@ -3,9 +3,12 @@ use serde::Deserialize; use std::fs; use std::path::PathBuf; +use std::sync::OnceLock; use crate::log::log; +static CONFIG_CACHE: OnceLock = OnceLock::new(); + /// Main configuration structure for the entire application. #[derive(Debug, Clone, Deserialize)] pub struct Config { @@ -42,6 +45,12 @@ pub struct RedisConfig { pub min_connections: Option, /// Idle connection timeout in seconds pub idle_timeout: Option, + /// Maximum time to wait for pool connection checkout in milliseconds + pub pool_get_timeout_ms: Option, + /// Maximum time to wait for individual Redis command execution in milliseconds + pub command_timeout_ms: Option, + /// Maximum time to wait for pool connection establishment in milliseconds + pub connect_timeout_ms: Option, } impl Default for RedisConfig { @@ -56,6 +65,9 @@ impl Default for RedisConfig { max_connections: Some(10), min_connections: Some(2), idle_timeout: Some(60), + pool_get_timeout_ms: Some(2000), + command_timeout_ms: Some(2000), + connect_timeout_ms: Some(2000), } } } @@ -93,30 +105,34 @@ impl RedisConfig { /// Loads configuration from the `config.toml` file with graceful fallback to defaults. pub fn load() -> Config { - let config_path = std::env::current_exe() - .ok() - .and_then(|exe| { - exe.parent() - .map(|dir| dir.join("@forge_server").join("config.toml")) - }) - .filter(|p| p.exists()) - .unwrap_or_else(|| PathBuf::from("@forge_server/config.toml")); + CONFIG_CACHE + .get_or_init(|| { + let config_path = std::env::current_exe() + .ok() + .and_then(|exe| { + exe.parent() + .map(|dir| dir.join("@forge_server").join("config.toml")) + }) + .filter(|p| p.exists()) + .unwrap_or_else(|| PathBuf::from("@forge_server/config.toml")); - match fs::read_to_string(&config_path) { - Ok(contents) => { - log("main", "INFO", &format!("Config file found! Loading...")); - match toml::from_str::(&contents) { - Ok(config) => config, - Err(_) => Config::default(), + match fs::read_to_string(&config_path) { + Ok(contents) => { + log("main", "INFO", &format!("Config file found! Loading...")); + match toml::from_str::(&contents) { + Ok(config) => config, + Err(_) => Config::default(), + } + } + Err(_) => { + log( + "main", + "INFO", + &format!("Config file not found. Using default configuration."), + ); + Config::default() + } } - } - Err(_) => { - log( - "main", - "INFO", - &format!("Config file not found. Using default configuration."), - ); - Config::default() - } - } + }) + .clone() } diff --git a/arma/server/extension/src/redis/macros.rs b/arma/server/extension/src/redis/macros.rs index 6115b6e..80e40ff 100644 --- a/arma/server/extension/src/redis/macros.rs +++ b/arma/server/extension/src/redis/macros.rs @@ -4,32 +4,54 @@ #[macro_export] macro_rules! redis_operation { ($conn:ident => $operation:block) => {{ + use tokio::time::{Duration, timeout}; use $crate::redis; use $crate::{CONNECTION_STATE, ConnectionState, REDIS_POOL, RUNTIME}; + let timeout_config = redis::config::load().redis; + let pool_get_timeout = + Duration::from_millis(timeout_config.pool_get_timeout_ms.unwrap_or(2000)); + let command_timeout = + Duration::from_millis(timeout_config.command_timeout_ms.unwrap_or(2000)); + let init_timeout = Duration::from_millis(timeout_config.connect_timeout_ms.unwrap_or(2000)); + // Get the Redis connection pool (initialized at startup) let pool = match REDIS_POOL.get() { Some(pool) => pool, None => { + if *CONNECTION_STATE.read().unwrap() == ConnectionState::Failed { + return "Error: Redis connection unavailable".to_string(); + } + // Attempt lazy initialization if not already initialized let rt = &RUNTIME; let init_result = rt.block_on(async move { let cfg = redis::config::load(); - match redis::client::create_redis_pool(&cfg.redis).await { - Ok(pool) => { + match timeout(init_timeout, redis::client::create_redis_pool(&cfg.redis)).await + { + Ok(Ok(pool)) => { let _ = REDIS_POOL.set(pool); Ok(()) } - Err(_e) => { + Ok(Err(_e)) => { let default_cfg = redis::RedisConfig::default(); - match redis::client::create_redis_pool(&default_cfg).await { - Ok(pool) => { + match timeout( + init_timeout, + redis::client::create_redis_pool(&default_cfg), + ) + .await + { + Ok(Ok(pool)) => { let _ = REDIS_POOL.set(pool); Ok(()) } - Err(e) => Err(format!("{}", e)), + Ok(Err(e)) => Err(format!("{}", e)), + Err(_) => { + Err("Redis fallback initialization timed out".to_string()) + } } } + Err(_) => Err("Redis initialization timed out".to_string()), } }); @@ -53,13 +75,17 @@ macro_rules! redis_operation { let rt = &RUNTIME; rt.block_on(async move { // Acquire a connection from the pool - let mut $conn = match pool.get().await { - Ok(conn) => conn, - Err(e) => return format!("Error: {}", e), + let mut $conn = match timeout(pool_get_timeout, pool.get()).await { + Ok(Ok(conn)) => conn, + Ok(Err(e)) => return format!("Error: {}", e), + Err(_) => return "Error: Redis connection checkout timed out".to_string(), }; // Execute the user-provided Redis operation - $operation + match timeout(command_timeout, async move { $operation }).await { + Ok(result) => result, + Err(_) => "Error: Redis operation timed out".to_string(), + } }) }}; } diff --git a/arma/server/extension/src/terrain.rs b/arma/server/extension/src/terrain.rs new file mode 100644 index 0000000..675ee63 --- /dev/null +++ b/arma/server/extension/src/terrain.rs @@ -0,0 +1,175 @@ +//! Terrain SVG export functionality via FFI to external C++ library. +//! +//! Provides commands to export terrain data to SVG format with various +//! rendering options (location names, grid, contour lines, etc.). + +use arma_rs::Group; + +#[cfg(target_os = "windows")] +mod windows_ffi { + use std::os::raw::{c_char, c_void}; + + #[link(name = "kernel32")] + unsafe extern "system" { + pub(super) fn GetModuleHandleA(lpModuleName: *const u8) -> *mut c_void; + pub(super) fn GetProcAddress(hModule: *mut c_void, lpProcName: *const u8) -> *mut c_void; + } + + pub(super) const EXPORT_SVG_PROC_NAME: &[u8] = b"?ExportSVG@@YAXPEBD_N11111@Z\0"; + + pub(super) type FnExportSVG = + extern "system" fn(*const c_char, bool, bool, bool, bool, bool, bool) -> *const c_void; +} + +/// Creates the Arma 3 command group for the terrain module. +/// +/// Registers the `exportSVG` command with the Arma 3 extension. +pub fn group() -> Group { + Group::new().command("exportSVG", export_svg) +} + +/// Exports terrain data to an SVG file with configurable rendering options. +/// +/// # Parameters +/// - `file_path`: Output SVG file path +/// - `draw_location_names`: Include location/place names +/// - `draw_grid`: Include grid overlay +/// - `draw_contourlines`: Include elevation contour lines +/// - `draw_tree_objects`: Include vegetation/tree objects +/// - `draw_mountain_heightpoints`: Include mountain peak elevation markers +/// - `simple_roads`: Use simplified road rendering +/// +/// # Returns +/// - `Ok(())` on success +/// - `Err(String)` with error message on failure +#[cfg(target_os = "windows")] +pub fn export_terrain_svg( + file_path: String, + draw_location_names: bool, + draw_grid: bool, + draw_contourlines: bool, + draw_tree_objects: bool, + draw_mountain_heightpoints: bool, + simple_roads: bool, +) -> Result<(), String> { + unsafe { + use windows_ffi::*; + + let module = GetModuleHandleA(std::ptr::null()); + if module.is_null() { + return Err("Failed to get game engine module handle".to_string()); + } + + let export_svg_proc = GetProcAddress(module, EXPORT_SVG_PROC_NAME.as_ptr()); + if export_svg_proc.is_null() { + return Err("Failed to find ExportSVG function in game engine".to_string()); + } + + let export_svg: FnExportSVG = std::mem::transmute(export_svg_proc); + let file_path_cstr = + std::ffi::CString::new(file_path).map_err(|e| format!("Invalid file path: {}", e))?; + + export_svg( + file_path_cstr.as_ptr(), + draw_location_names, + draw_grid, + draw_contourlines, + draw_tree_objects, + draw_mountain_heightpoints, + simple_roads, + ); + + Ok(()) + } +} + +#[cfg(not(target_os = "windows"))] +pub fn export_terrain_svg( + _file_path: String, + _draw_location_names: bool, + _draw_grid: bool, + _draw_contourlines: bool, + _draw_tree_objects: bool, + _draw_mountain_heightpoints: bool, + _simple_roads: bool, +) -> Result<(), String> { + Err("Terrain SVG export is only available on Windows".to_string()) +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExportSvgOptions { + file_path: String, + #[serde(default)] + draw_location_names: bool, + #[serde(default)] + draw_grid: bool, + #[serde(default)] + draw_contourlines: bool, + #[serde(default)] + draw_tree_objects: bool, + #[serde(default)] + draw_mountain_heightpoints: bool, + #[serde(default)] + simple_roads: bool, +} + +/// Arma command handler for terrain SVG export. +/// +/// # SQF Usage +/// ```sqf +/// // Register callback handler (optional, for async result) +/// ["terrain:exportSVG", { +/// params ["_response"]; +/// systemChat format ["Export %1: %2", +/// _response get "status", +/// _response get "message" +/// ]; +/// }] call forge_x_extension_fnc_setHandler; +/// +/// // Create options and call extension +/// private _options = createHashMapFromArray [ +/// ["filePath", "C:\terrain.svg"], +/// ["drawLocationNames", true], +/// ["drawGrid", true], +/// ["drawContourlines", true], +/// ["drawTreeObjects", false], +/// ["drawMountainHeightpoints", true], +/// ["simpleRoads", false] +/// ]; +/// +/// ["terrain:exportSVG", [toJSON _options]] call forge_x_extension_fnc_extCall; +/// ``` +fn export_svg(options_json: String) -> String { + let options: ExportSvgOptions = match serde_json::from_str(&options_json) { + Ok(opts) => opts, + Err(e) => { + return serde_json::json!({ + "status": "error", + "message": format!("Invalid JSON options: {}", e) + }) + .to_string(); + } + }; + + match export_terrain_svg( + options.file_path, + options.draw_location_names, + options.draw_grid, + options.draw_contourlines, + options.draw_tree_objects, + options.draw_mountain_heightpoints, + options.simple_roads, + ) { + Ok(_) => serde_json::json!({ + "status": "success", + "message": "Terrain exported successfully" + }) + .to_string(), + Err(e) => serde_json::json!({ + "status": "error", + "message": e + }) + .to_string(), + } +} diff --git a/arma/server/extension/src/v_garage.rs b/arma/server/extension/src/v_garage.rs index 0b015df..17506e9 100644 --- a/arma/server/extension/src/v_garage.rs +++ b/arma/server/extension/src/v_garage.rs @@ -428,9 +428,12 @@ pub fn vgarage_exists(call_context: CallContext, key: String) -> String { uid } None => { - let error_msg = format!("Error: Failed to resolve UID for key: {}", key); - log("v_garage", "ERROR", &error_msg); - return error_msg; + log( + "v_garage", + "WARN", + &format!("Failed to resolve UID for key: {}", key), + ); + return "false".to_string(); } }; @@ -438,22 +441,21 @@ pub fn vgarage_exists(call_context: CallContext, key: String) -> String { Ok(exists) => { log( "v_garage", - "INFO", - &format!("Virtual garage exists for: {}", resolved_uid), + "DEBUG", + &format!("Virtual garage '{}' exists: {}", resolved_uid, exists), ); exists.to_string() } Err(e) => { - let error_msg = format!("Error: {}", e); log( "v_garage", "ERROR", &format!( - "Failed to check if virtual garage exists for '{}': {}", + "Failed to check if virtual garage '{}' exists: {}", resolved_uid, e ), ); - error_msg + "false".to_string() } } } diff --git a/arma/server/extension/src/v_locker.rs b/arma/server/extension/src/v_locker.rs index 8b4d98a..11be05e 100644 --- a/arma/server/extension/src/v_locker.rs +++ b/arma/server/extension/src/v_locker.rs @@ -424,9 +424,12 @@ pub fn vlocker_exists(call_context: CallContext, key: String) -> String { uid } None => { - let error_msg = format!("Error: Failed to resolve UID for key: {}", key); - log("v_locker", "ERROR", &error_msg); - return error_msg; + log( + "v_locker", + "WARN", + &format!("Failed to resolve UID for key: {}", key), + ); + return "false".to_string(); } }; @@ -434,22 +437,21 @@ pub fn vlocker_exists(call_context: CallContext, key: String) -> String { Ok(exists) => { log( "v_locker", - "INFO", - &format!("Virtual locker exists for: {}", resolved_uid), + "DEBUG", + &format!("Virtual locker '{}' exists: {}", resolved_uid, exists), ); exists.to_string() } Err(e) => { - let error_msg = format!("Error: {}", e); log( "v_locker", "ERROR", &format!( - "Failed to check if virtual locker exists for '{}': {}", + "Failed to check if virtual locker '{}' exists: {}", resolved_uid, e ), ); - error_msg + "false".to_string() } } } diff --git a/arma/server/server.code-workspace b/arma/server/server.code-workspace index e372f7e..ceb7f29 100644 --- a/arma/server/server.code-workspace +++ b/arma/server/server.code-workspace @@ -1,8 +1,8 @@ { "folders": [ { - "path": "." - } + "path": ".", + }, ], "settings": { "editor.insertSpaces": true, @@ -18,7 +18,7 @@ "*.hpp": "arma-config", "*.inc": "arma-config", "*.cfg": "arma-config", - "*.rvmat": "arma-config" - } - } + "*.rvmat": "arma-config", + }, + }, } diff --git a/arma/ui/apps/base.css b/arma/ui/apps/base.css new file mode 100644 index 0000000..a05b977 --- /dev/null +++ b/arma/ui/apps/base.css @@ -0,0 +1,175 @@ +:root { + --bg-app: #fdfcf8; + --bg-surface: #ffffff; + --bg-surface-hover: #f1f5f9; + --primary: #475569; + --primary-hover: #1e293b; + --text-main: #1f2937; + --text-muted: #64748b; + --text-inverse: #f8fafc; + --border: #e2e8f0; + --radius: 8px; + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --footer-bg: #1e293b; +} + +body { + font-family: + "Inter", + system-ui, + -apple-system, + sans-serif; + margin: 0; + padding: 0; + background: var(--bg-app); + color: var(--text-main); + line-height: 1.6; +} + +#app { + min-height: 100vh; +} + +main { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.container { + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 2rem; + flex: 1; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +.header { + text-align: center; + margin-bottom: 3rem; + padding-bottom: 2rem; + border-bottom: 1px solid var(--border); + + h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 0.5rem; + letter-spacing: -0.025em; + color: var(--primary-hover); + } + + p { + color: var(--text-muted); + font-size: 1.1rem; + } +} + +.card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + box-shadow: var(--shadow); + text-align: center; + + h2 { + margin-top: 0; + font-size: 1.8rem; + color: var(--primary-hover); + } +} + +button { + background: var(--primary); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: var(--radius); + cursor: pointer; + font-size: 1rem; + font-weight: 500; + transition: all 0.2s ease; + + &:hover { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.65; + transform: none; + box-shadow: none; + } + + & + & { + margin-left: 1rem; + } +} + +.footer { + margin-top: auto; + background: var(--footer-bg); + color: var(--text-inverse); + display: block; + + .wrapper { + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 3rem 2rem; + box-sizing: border-box; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4rem; + } + + h3 { + color: var(--text-inverse); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 700; + margin-bottom: 1.5rem; + border-bottom: 1px solid #475569; + padding-bottom: 0.5rem; + margin-right: 1rem; + } + + ul { + li { + color: #cbd5e1; + font-size: 0.95rem; + margin-bottom: 0.75rem; + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: white; + } + } + } +} + +@media (max-width: 960px) { + .container { + padding: 1.5rem; + } + + .header { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + + h1 { + font-size: 2rem; + } + } + + .footer .wrapper { + grid-template-columns: 1fr; + } +} diff --git a/arma/ui/apps/components/AppShell.js b/arma/ui/apps/components/AppShell.js new file mode 100644 index 0000000..aa56bfc --- /dev/null +++ b/arma/ui/apps/components/AppShell.js @@ -0,0 +1,107 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h } = RegistryApp.runtime; + const store = RegistryApp.store; + + RegistryApp.components = RegistryApp.components || {}; + + RegistryApp.components.App = function App() { + const Navbar = window.SharedUI.componentFns.Navbar; + const Header = window.SharedUI.componentFns.Header; + const Footer = window.SharedUI.componentFns.Footer; + const HomeView = RegistryApp.componentFns.HomeView; + const RegistrationView = RegistryApp.componentFns.RegistrationView; + const PortalApp = + window.OrgPortal && window.OrgPortal.components + ? window.OrgPortal.components.App + : null; + + const view = store.getView(); + const viewLabel = + view === "create" + ? "Organization Registration" + : view === "portal" + ? "Organization Portal" + : "Entry Hub"; + const actionLabel = view === "portal" ? "Sign Out" : "Close"; + const footerSections = [ + { + title: "Registry Resources", + items: [ + "Registration Guidelines", + "Tax & Fee Schedule", + "Legal Compliance", + "Trademark Database", + ], + }, + { + title: "Bureau Support", + items: [ + "Office: Sector 7 Admin Block", + "Hours: 0800 - 1600 (GST)", + "Helpdesk: 555-01-REGISTRY", + "support@org-bureau.gov", + ], + }, + ]; + + function closeRegistry() { + if ( + typeof A3API !== "undefined" && + typeof A3API.SendAlert === "function" + ) { + A3API.SendAlert( + JSON.stringify({ + event: "org::close", + data: {}, + }), + ); + return; + } + + store.setView("home"); + } + + if (view === "portal" && PortalApp) { + return h( + "div", + null, + Navbar({ + title: "Global Organization Network", + viewLabel, + actionLabel, + onAction: closeRegistry, + }), + PortalApp(), + ); + } + + let mainContent; + if (view === "home") { + mainContent = HomeView(); + } else if (view === "create") { + mainContent = RegistrationView(); + } + + return h( + "main", + null, + Navbar({ + title: "Global Organization Network", + viewLabel, + actionLabel, + onAction: closeRegistry, + }), + h( + "div", + { className: "container" }, + Header({ + title: "Global Organization Network", + onTitleClick: () => store.setView("home"), + }), + mainContent, + ), + Footer({ sections: footerSections }), + ); + }; +})(); diff --git a/arma/ui/apps/components/footer.js b/arma/ui/apps/components/footer.js new file mode 100644 index 0000000..4401f4b --- /dev/null +++ b/arma/ui/apps/components/footer.js @@ -0,0 +1,32 @@ +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h } = RegistryApp.runtime; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Footer = function Footer({ sections = [] }) { + return h( + "div", + { className: "footer" }, + h( + "div", + { className: "wrapper" }, + ...sections.map((section) => + h( + "div", + null, + h("h3", null, section.title), + h( + "ul", + { style: { listStyleType: "none", padding: 0 } }, + ...(section.items || []).map((item) => + h("li", null, item), + ), + ), + ), + ), + ), + ); + }; +})(); diff --git a/arma/ui/apps/components/header.js b/arma/ui/apps/components/header.js new file mode 100644 index 0000000..6734ac1 --- /dev/null +++ b/arma/ui/apps/components/header.js @@ -0,0 +1,27 @@ +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h } = RegistryApp.runtime; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Header = function Header({ + title, + subtitle = "Organization Registration & Management Portal", + onTitleClick = null, + }) { + return h( + "div", + { className: "header" }, + h( + "h1", + { + style: { cursor: onTitleClick ? "pointer" : "default" }, + onClick: onTitleClick, + }, + title, + ), + h("p", null, subtitle), + ); + }; +})(); diff --git a/arma/ui/apps/components/hero.js b/arma/ui/apps/components/hero.js new file mode 100644 index 0000000..a022e70 --- /dev/null +++ b/arma/ui/apps/components/hero.js @@ -0,0 +1,35 @@ +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h } = OrgPortal.runtime; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Hero = function Hero({ + className = "", + kicker = "", + title = "", + subtitle = "", + meta = "", + }) { + const finalClassName = [ + "card org-panel org-span-12 org-page-header", + className, + ] + .filter(Boolean) + .join(" "); + + return h( + "section", + { className: finalClassName }, + h( + "div", + { className: "org-page-heading" }, + h("span", { className: "org-page-kicker" }, kicker), + h("h1", { className: "org-page-title" }, title), + h("p", { className: "org-page-subtitle" }, subtitle), + h("span", { className: "org-page-meta" }, meta), + ), + ); + }; +})(); diff --git a/arma/ui/apps/components/modal.js b/arma/ui/apps/components/modal.js new file mode 100644 index 0000000..862b115 --- /dev/null +++ b/arma/ui/apps/components/modal.js @@ -0,0 +1,190 @@ +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const scopeAttr = "data-ui-modal"; + const scopeSelector = `[${scopeAttr}]`; + const modalCss = ` +${scopeSelector} { + position: fixed; + inset: 0; + background: rgb(15 23 42 / 0.38); + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + z-index: 20; +} + +${scopeSelector} .app-modal-card { + width: min(100%, 30rem); + margin-bottom: 0; + text-align: left; +} + +${scopeSelector} .app-modal-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +${scopeSelector} .app-modal-title { + margin: 0; + color: var(--primary-hover); + font-size: 1.45rem; +} + +${scopeSelector} .app-modal-close { + width: 2.25rem; + height: 2.25rem; + padding: 0; + background: var(--bg-surface); + color: var(--text-main); + border: 1px solid var(--border); + box-shadow: none; + transform: none; +} + +${scopeSelector} .app-modal-close:hover { + background: var(--bg-surface-hover); + color: var(--text-main); + box-shadow: none; + transform: none; +} + +${scopeSelector} .app-modal-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +${scopeSelector} .app-modal-form label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-muted); + font-weight: 500; + font-size: 0.9rem; +} + +${scopeSelector} .app-modal-form input, +${scopeSelector} .app-modal-form select { + width: 100%; + padding: 0.75rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-app); + color: var(--text-main); + font-family: inherit; + font-size: 1rem; + box-sizing: border-box; + transition: border-color 0.2s, box-shadow 0.2s; +} + +${scopeSelector} .app-modal-form input:focus, +${scopeSelector} .app-modal-form select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgb(71 85 105 / 0.12); +} + +${scopeSelector} .app-modal-form input:disabled, +${scopeSelector} .app-modal-form select:disabled { + background: #f1f5f9; + color: var(--text-muted); + cursor: not-allowed; +} + +${scopeSelector} .app-modal-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 0.5rem; +} + +${scopeSelector} .app-modal-actions button + button, +${scopeSelector} .app-modal-danger-actions button + button { + margin-left: 0; +} + +${scopeSelector} .app-modal-danger { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border: 1px solid #fecaca; + border-radius: var(--radius); + background: #fff1f2; + align-items: flex-start; +} + +${scopeSelector} .app-modal-danger p { + margin: 0; + color: var(--text-main); +} + +${scopeSelector} .app-modal-danger-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .app-modal-head, + ${scopeSelector} .app-modal-danger { + flex-direction: column; + align-items: flex-start; + } +} +`; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Modal = function Modal({ + title = "", + body = null, + onClose = null, + }) { + ensureScopedStyle("shared-modal", modalCss); + + return h( + "div", + { + className: "app-modal-backdrop", + [scopeAttr]: "", + onClick: (e) => { + if (e.target === e.currentTarget && onClose) { + onClose(); + } + }, + }, + h( + "div", + { className: "card app-modal-card" }, + h( + "div", + { className: "app-modal-head" }, + h( + "div", + null, + h("h2", { className: "app-modal-title" }, title), + ), + h( + "button", + { + type: "button", + className: "app-modal-close", + onClick: onClose, + "aria-label": "Close dialog", + }, + "x", + ), + ), + body, + ), + ); + }; +})(); diff --git a/arma/ui/apps/components/navbar.js b/arma/ui/apps/components/navbar.js new file mode 100644 index 0000000..2b096bb --- /dev/null +++ b/arma/ui/apps/components/navbar.js @@ -0,0 +1,129 @@ +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const scopeAttr = "data-ui-navbar"; + const scopeSelector = `[${scopeAttr}]`; + const navbarCss = ` +${scopeSelector} { + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow); +} + +${scopeSelector} .app-navbar-inner { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 1rem 2rem; + box-sizing: border-box; +} + +${scopeSelector} .app-navbar-brand { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +${scopeSelector} .app-navbar-kicker { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 600; +} + +${scopeSelector} .app-navbar-title { + font-size: 1.25rem; + font-weight: 700; + color: var(--primary-hover); + letter-spacing: -0.025em; +} + +${scopeSelector} .app-navbar-actions { + display: flex; + align-items: center; + gap: 1.5rem; +} + +${scopeSelector} .app-navbar-view { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + font-weight: 600; +} + +${scopeSelector} .app-close-btn { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border); + padding: 0.5rem 1rem; + font-size: 0.85rem; +} + +${scopeSelector} .app-close-btn:hover { + background: var(--bg-surface-hover); + color: var(--primary-hover); + border-color: var(--primary); + transform: none; + box-shadow: none; +} + +@media (max-width: 960px) { + ${scopeSelector} .app-navbar-inner { + flex-direction: column; + align-items: flex-start; + padding: 1rem 1.5rem; + } + + ${scopeSelector} .app-navbar-actions { + align-items: flex-start; + } +} +`; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.Navbar = function Navbar({ + kicker = "ORBIS", + title = "", + viewLabel = "", + actionLabel = "", + onAction = null, + }) { + ensureScopedStyle("shared-navbar", navbarCss); + + return h( + "nav", + { className: "app-navbar", [scopeAttr]: "" }, + h( + "div", + { className: "app-navbar-inner" }, + h( + "div", + { className: "app-navbar-brand" }, + h("span", { className: "app-navbar-kicker" }, kicker), + h("span", { className: "app-navbar-title" }, title), + ), + h( + "div", + { className: "app-navbar-actions" }, + h("span", { className: "app-navbar-view" }, viewLabel), + h( + "button", + { + type: "button", + className: "app-close-btn", + onClick: onAction, + }, + actionLabel, + ), + ), + ), + ); + }; +})(); diff --git a/arma/ui/apps/components/panelCard.js b/arma/ui/apps/components/panelCard.js new file mode 100644 index 0000000..b927c98 --- /dev/null +++ b/arma/ui/apps/components/panelCard.js @@ -0,0 +1,83 @@ +(function () { + const SharedUI = (window.SharedUI = window.SharedUI || {}); + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const scopeAttr = "data-ui-panel-card"; + const scopeSelector = `[${scopeAttr}]`; + const panelCardCss = ` +${scopeSelector} .org-panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; +} + +${scopeSelector} .org-eyebrow { + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 0.4rem; +} + +${scopeSelector} .org-panel-title { + margin: 0; + color: var(--primary-hover); + font-size: 1.45rem; +} + +${scopeSelector} .org-panel-subtitle { + margin: 0.35rem 0 0; + color: var(--text-muted); + font-size: 0.95rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-panel-head { + flex-direction: column; + align-items: flex-start; + } +} +`; + + SharedUI.componentFns = SharedUI.componentFns || {}; + + SharedUI.componentFns.PanelCard = function PanelCard({ + className = "", + eyebrow = "", + title = "", + subtitle = "", + headerExtras = null, + body = null, + rootProps = {}, + }) { + const finalClassName = ["card org-panel", className] + .filter(Boolean) + .join(" "); + ensureScopedStyle("shared-panel-card", panelCardCss); + + return h( + "section", + { className: finalClassName, [scopeAttr]: "", ...rootProps }, + h( + "div", + { className: "org-panel-head" }, + h( + "div", + null, + eyebrow + ? h("div", { className: "org-eyebrow" }, eyebrow) + : null, + h("h2", { className: "org-panel-title" }, title), + subtitle + ? h("p", { className: "org-panel-subtitle" }, subtitle) + : null, + ), + headerExtras, + ), + body, + ); + }; +})(); diff --git a/arma/ui/apps/components/portal/activityCard.js b/arma/ui/apps/components/portal/activityCard.js new file mode 100644 index 0000000..6060f41 --- /dev/null +++ b/arma/ui/apps/components/portal/activityCard.js @@ -0,0 +1,79 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const scopeAttr = "data-ui-activity-card"; + const scopeSelector = `[${scopeAttr}]`; + const activityCardCss = ` +${scopeSelector} .org-activity-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 0.85rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-activity-row { + padding: 1rem; + border: 1px solid var(--border); + border-left: 3px solid #94a3b8; + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-activity-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); + border-left-color: #64748b; +} + +${scopeSelector} .org-activity-row p { + margin: 0; + color: var(--text-main); +} + +${scopeSelector} .org-activity-time { + display: inline-block; + margin-bottom: 0.35rem; + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.ActivityCard = function ActivityCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + ensureScopedStyle("portal-activity-card", activityCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-6", + title: "Command Feed", + subtitle: "Recent organization-level actions and updates.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-activity-list" }, + ...portalData.activity.map((item) => + h( + "article", + { className: "org-activity-row" }, + h( + "span", + { className: "org-activity-time" }, + item.time, + ), + h("p", null, item.text), + ), + ), + ), + }); + }; +})(); diff --git a/arma/ui/apps/components/portal/assetsCard.js b/arma/ui/apps/components/portal/assetsCard.js new file mode 100644 index 0000000..8b65094 --- /dev/null +++ b/arma/ui/apps/components/portal/assetsCard.js @@ -0,0 +1,94 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const actions = OrgPortal.actions; + const scopeAttr = "data-ui-assets-card"; + const scopeSelector = `[${scopeAttr}]`; + const assetsCardCss = ` +${scopeSelector} .org-simple-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 0.85rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-simple-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-simple-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-simple-name { + color: var(--primary-hover); +} + +${scopeSelector} .org-simple-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 1rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-simple-row { + flex-direction: column; + align-items: flex-start; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.AssetsCard = function AssetsCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + const SimpleStat = OrgPortal.componentFns.SimpleStat; + ensureScopedStyle("portal-assets-card", assetsCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-7", + title: "Assets", + subtitle: "Inventory supplies and equipment with quantity totals.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-simple-list" }, + ...portalData.assets.map((asset) => + h( + "article", + { className: "org-simple-row" }, + h( + "strong", + { className: "org-simple-name" }, + asset.name, + ), + h( + "div", + { className: "org-simple-meta" }, + SimpleStat( + "Type", + actions.formatAssetType(asset.type), + ), + SimpleStat("Quantity", asset.quantity), + ), + ), + ), + ), + }); + }; +})(); diff --git a/arma/ui/apps/components/portal/dangerCard.js b/arma/ui/apps/components/portal/dangerCard.js new file mode 100644 index 0000000..4c825c3 --- /dev/null +++ b/arma/ui/apps/components/portal/dangerCard.js @@ -0,0 +1,70 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const permissions = OrgPortal.permissions; + const actions = OrgPortal.actions; + const scopeAttr = "data-ui-danger-card"; + const scopeSelector = `[${scopeAttr}]`; + const dangerCardCss = ` +${scopeSelector} { + border-color: #fecaca; + background: linear-gradient(180deg, #ffffff 0%, #fff7f7 100%); +} + +${scopeSelector} .org-danger-copy { + margin-bottom: 1rem; +} + +${scopeSelector} .org-danger-copy strong, +${scopeSelector} .org-danger-copy p { + display: block; +} + +${scopeSelector} .org-danger-copy p { + margin: 0.4rem 0 0; + color: var(--text-muted); +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.DangerCard = function DangerCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + ensureScopedStyle("portal-danger-card", dangerCardCss); + + if (!permissions.canDisbandOrg()) { + return null; + } + + return PanelCard({ + className: "org-span-12 org-danger-panel", + title: "Organization Controls", + subtitle: + "Leader-only actions for membership and permanent organization removal.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + null, + h( + "div", + { className: "org-danger-copy" }, + h("strong", null, "Disband organization"), + h( + "p", + null, + "This removes the organization and revokes access to the portal for all members.", + ), + ), + h( + "button", + { + type: "button", + className: "org-danger-btn", + onClick: () => actions.openModal("disband"), + }, + "Disband Organization", + ), + ), + }); + }; +})(); diff --git a/arma/ui/apps/components/portal/fleetCard.js b/arma/ui/apps/components/portal/fleetCard.js new file mode 100644 index 0000000..6869901 --- /dev/null +++ b/arma/ui/apps/components/portal/fleetCard.js @@ -0,0 +1,101 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const actions = OrgPortal.actions; + const scopeAttr = "data-ui-fleet-card"; + const scopeSelector = `[${scopeAttr}]`; + const fleetCardCss = ` +${scopeSelector} .org-simple-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 0.85rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} { + min-height: 32.5rem; + max-height: 32.5rem; +} + +${scopeSelector} .org-simple-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-simple-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-simple-name { + color: var(--primary-hover); +} + +${scopeSelector} .org-simple-meta { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 1rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-simple-row { + flex-direction: column; + align-items: flex-start; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.FleetCard = function FleetCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + const SimpleStat = OrgPortal.componentFns.SimpleStat; + ensureScopedStyle("portal-fleet-card", fleetCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-7", + title: "Fleet", + subtitle: + "Individual vehicles with type, status, and overall damage.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-simple-list" }, + ...portalData.fleet.map((unit) => + h( + "article", + { className: "org-simple-row" }, + h( + "strong", + { className: "org-simple-name" }, + unit.name, + ), + h( + "div", + { className: "org-simple-meta" }, + SimpleStat( + "Type", + actions.formatVehicleType(unit.type), + ), + SimpleStat("Status", unit.status), + SimpleStat("Damage", unit.damage), + ), + ), + ), + ), + }); + }; +})(); diff --git a/arma/ui/apps/components/portal/futureCard.js b/arma/ui/apps/components/portal/futureCard.js new file mode 100644 index 0000000..343835f --- /dev/null +++ b/arma/ui/apps/components/portal/futureCard.js @@ -0,0 +1,126 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const scopeAttr = "data-ui-future-card"; + const ROADMAP = [ + { + name: "Contracts Board", + status: "Planned", + detail: "Track payouts, assignments, and claim approvals.", + }, + { + name: "Diplomacy", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + { + name: "Logistics Queue", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + { + name: "Permissions", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + ]; + const scopeSelector = `[${scopeAttr}]`; + const futureCardCss = ` +${scopeSelector} .org-roadmap-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + flex: 1; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-roadmap-card { + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.7rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-roadmap-card:nth-child(4n + 2), +${scopeSelector} .org-roadmap-card:nth-child(4n + 3) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(100 116 139 / 0.4); +} + +${scopeSelector} .org-roadmap-card p { + margin: 0; + color: var(--text-main); +} + +${scopeSelector} .org-list-tag { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + background: #e2e8f0; + color: var(--primary-hover); +} + +${scopeSelector} .org-roadmap-card:nth-child(4n + 2) .org-list-tag, +${scopeSelector} .org-roadmap-card:nth-child(4n + 3) .org-list-tag { + background: #cbd5e1; + color: #1e293b; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-roadmap-grid { + grid-template-columns: 1fr; + } + + ${scopeSelector} .org-roadmap-card:nth-child(4n + 3) { + background: #f8fafc; + border-color: var(--border); + } + + ${scopeSelector} .org-roadmap-card:nth-child(4n + 3) .org-list-tag { + background: #e2e8f0; + color: var(--primary-hover); + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.FutureCard = function FutureCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + ensureScopedStyle("portal-future-card", futureCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-6", + title: "Expansion Slots", + subtitle: + "Potential modules are tagged by status such as Planned, In Design, In Review, and Future Review.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-roadmap-grid" }, + ...ROADMAP.map((item) => + h( + "article", + { className: "org-roadmap-card" }, + h("span", { className: "org-list-tag" }, item.status), + h("strong", null, item.name), + h("p", null, item.detail), + ), + ), + ), + }); + }; +})(); diff --git a/arma/ui/apps/components/portal/membersCard.js b/arma/ui/apps/components/portal/membersCard.js new file mode 100644 index 0000000..3bc616e --- /dev/null +++ b/arma/ui/apps/components/portal/membersCard.js @@ -0,0 +1,116 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const store = OrgPortal.store; + const permissions = OrgPortal.permissions; + const actions = OrgPortal.actions; + const scopeAttr = "data-ui-members-card"; + const scopeSelector = `[${scopeAttr}]`; + const membersCardCss = ` +${scopeSelector} .org-name-list { + display: flex; + flex-direction: column; + flex: 1; + gap: 0.85rem; + min-height: 0; + overflow: auto; + padding-right: 0.35rem; + scrollbar-width: thin; + scrollbar-color: #94a3b8 #e2e8f0; +} + +${scopeSelector} .org-name-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-name-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-name-row button { + margin-left: auto; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-name-row { + flex-direction: column; + align-items: flex-start; + } + + ${scopeSelector} .org-name-row button { + margin-left: 0; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.MembersCard = function MembersCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + const members = store.getMembers(); + const allowMemberManagement = permissions.canManageMembers(); + ensureScopedStyle("portal-members-card", membersCardCss); + + return PanelCard({ + className: "org-scroll-panel org-span-5", + title: "Members", + subtitle: + "Current roster listing. The organization owner cannot be removed.", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-name-list" }, + ...members.map((member) => { + const canRemoveMember = + allowMemberManagement && + !actions.isOwnerMember(member.name); + + return h( + "article", + { className: "org-name-row" }, + h("strong", null, member.name), + canRemoveMember + ? h( + "button", + { + type: "button", + className: "org-danger-btn org-icon-btn", + title: `Remove ${member.name}`, + "aria-label": `Remove ${member.name}`, + onClick: () => + actions.removeMember(member.name), + }, + h( + "svg", + { + className: "org-icon", + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "aria-hidden": "true", + }, + h("path", { d: "M9 3h6" }), + h("path", { d: "M4 7h16" }), + h("path", { d: "M6 7l1 13h10l1-13" }), + h("path", { d: "M10 11v6" }), + h("path", { d: "M14 11v6" }), + ), + ) + : null, + ); + }), + ), + }); + }; +})(); diff --git a/arma/ui/apps/components/portal/metricCard.js b/arma/ui/apps/components/portal/metricCard.js new file mode 100644 index 0000000..ed0d7a3 --- /dev/null +++ b/arma/ui/apps/components/portal/metricCard.js @@ -0,0 +1,77 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const scopeAttr = "data-ui-metric-card"; + const scopeSelector = `[${scopeAttr}]`; + const metricCardCss = ` +${scopeSelector} { + display: flex; + flex-direction: column; + gap: 0.45rem; + padding: 1rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); +} + +${scopeSelector}:nth-child(4n + 2), +${scopeSelector}:nth-child(4n + 3) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(226 232 240) 100%); + border-color: rgb(100 116 139 / 0.35); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.6); +} + +${scopeSelector} .org-metric-label { + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); +} + +${scopeSelector} .org-metric-value { + font-size: 1.8rem; + color: var(--primary-hover); + line-height: 1.1; +} + +${scopeSelector}:nth-child(4n + 2) .org-metric-value, +${scopeSelector}:nth-child(4n + 3) .org-metric-value { + color: #334155; +} + +${scopeSelector} .org-metric-note { + color: var(--text-muted); + font-size: 0.9rem; +} + +@media (max-width: 960px) { + ${scopeSelector}:nth-child(4n + 3) { + background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); + border-color: var(--border); + box-shadow: none; + } + + ${scopeSelector}:nth-child(4n + 3) .org-metric-value { + color: var(--primary-hover); + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.MetricCard = function MetricCard( + label, + value, + note, + ) { + ensureScopedStyle("portal-metric-card", metricCardCss); + + return h( + "div", + { className: "org-metric-card", [scopeAttr]: "" }, + h("span", { className: "org-metric-label" }, label), + h("strong", { className: "org-metric-value" }, value), + h("span", { className: "org-metric-note" }, note), + ); + }; +})(); diff --git a/arma/ui/apps/components/portal/modalLayer.js b/arma/ui/apps/components/portal/modalLayer.js new file mode 100644 index 0000000..a0f0d3b --- /dev/null +++ b/arma/ui/apps/components/portal/modalLayer.js @@ -0,0 +1,257 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const store = OrgPortal.store; + const actions = OrgPortal.actions; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.ModalLayer = function ModalLayer() { + const Modal = window.SharedUI.componentFns.Modal; + const modal = store.getModal(); + if (!modal) { + return null; + } + + const members = store.getMembers(); + const memberSelectProps = + members.length === 0 ? { disabled: true } : {}; + + let title = ""; + let body = null; + + if (modal.type === "payroll") { + title = "Run Payroll"; + body = h( + "div", + { className: "app-modal-form" }, + h( + "div", + null, + h("label", null, "Amount Per Member"), + h("input", { + id: "treasury-payroll-amount", + type: "number", + min: "1", + placeholder: "500", + autofocus: "true", + }), + ), + h( + "div", + { className: "app-modal-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + onClick: () => { + if ( + actions.runPayroll( + actions.parseAmount( + actions.getInputValue( + "treasury-payroll-amount", + ), + ), + ) + ) { + actions.closeModal(); + } + }, + }, + "Run Payroll", + ), + ), + ); + } else if (modal.type === "transfer") { + title = "Send Funds"; + body = h( + "div", + { className: "app-modal-form" }, + h( + "div", + null, + h("label", null, "Member"), + h( + "select", + { + id: "treasury-transfer-member", + ...memberSelectProps, + }, + ...members.map((member) => + h("option", { value: member.name }, member.name), + ), + ), + ), + h( + "div", + null, + h("label", null, "Amount"), + h("input", { + id: "treasury-transfer-amount", + type: "number", + min: "1", + placeholder: "1500", + }), + ), + h( + "div", + { className: "app-modal-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + ...memberSelectProps, + onClick: () => { + if ( + actions.sendFundsToMember( + String( + actions.getInputValue( + "treasury-transfer-member", + ) || "", + ), + actions.parseAmount( + actions.getInputValue( + "treasury-transfer-amount", + ), + ), + ) + ) { + actions.closeModal(); + } + }, + }, + "Send Funds", + ), + ), + ); + } else if (modal.type === "credit") { + title = "Assign Credit Line"; + body = h( + "div", + { className: "app-modal-form" }, + h( + "div", + null, + h("label", null, "Member"), + h( + "select", + { id: "treasury-credit-member", ...memberSelectProps }, + ...members.map((member) => + h("option", { value: member.name }, member.name), + ), + ), + ), + h( + "div", + null, + h("label", null, "Credit Amount"), + h("input", { + id: "treasury-credit-amount", + type: "number", + min: "1", + placeholder: "5000", + }), + ), + h( + "div", + { className: "app-modal-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + ...memberSelectProps, + onClick: () => { + if ( + actions.grantCreditLine( + String( + actions.getInputValue( + "treasury-credit-member", + ) || "", + ), + actions.parseAmount( + actions.getInputValue( + "treasury-credit-amount", + ), + ), + ) + ) { + actions.closeModal(); + } + }, + }, + "Assign Credit Line", + ), + ), + ); + } else if (modal.type === "disband") { + title = "Disband Organization"; + body = h( + "div", + { className: "app-modal-danger" }, + h( + "p", + null, + "This action is permanent. Disband ", + portalData.org.name, + "?", + ), + h( + "div", + { className: "app-modal-danger-actions" }, + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => actions.closeModal(), + }, + "Cancel", + ), + h( + "button", + { + type: "button", + className: "org-danger-btn", + onClick: () => actions.disbandOrganization(), + }, + "Confirm Disband", + ), + ), + ); + } + + return Modal({ + title, + body, + onClose: () => actions.closeModal(), + }); + }; +})(); diff --git a/arma/ui/apps/components/portal/overviewCard.js b/arma/ui/apps/components/portal/overviewCard.js new file mode 100644 index 0000000..87fada3 --- /dev/null +++ b/arma/ui/apps/components/portal/overviewCard.js @@ -0,0 +1,175 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const store = OrgPortal.store; + const actions = OrgPortal.actions; + const scopeAttr = "data-ui-overview-card"; + const scopeSelector = `[${scopeAttr}]`; + const overviewCardCss = ` +${scopeSelector} .org-hero-grid { + display: grid; + grid-template-columns: 1.3fr 1fr; + gap: 1.5rem; + align-items: start; +} + +${scopeSelector} .org-summary { + margin: 0; + font-size: 1.05rem; + color: var(--text-main); +} + +${scopeSelector} .org-meta-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +${scopeSelector} .org-meta-item { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-meta-item:nth-child(even) { + background: linear-gradient(180deg, rgb(241 245 249) 0%, rgb(226 232 240) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-meta-label { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +${scopeSelector} .org-meta-value { + font-size: 1rem; + font-weight: 600; + color: var(--primary-hover); +} + +${scopeSelector} .org-metric-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +@media (max-width: 960px) { + ${scopeSelector} .org-hero-grid, + ${scopeSelector} .org-meta-row, + ${scopeSelector} .org-metric-grid { + grid-template-columns: 1fr; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.OverviewCard = function OverviewCard() { + const MetricCard = OrgPortal.componentFns.MetricCard; + const PanelCard = window.SharedUI.componentFns.PanelCard; + const readiness = actions.getAssetReadiness(); + const headquarters = portalData.org.headquarters || "ArmA Verse"; + ensureScopedStyle("portal-overview-card", overviewCardCss); + + return PanelCard({ + className: "org-span-12", + eyebrow: portalData.org.tag, + title: "Organization Overview", + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + { className: "org-hero-grid" }, + h( + "div", + { className: "org-hero-copy" }, + h( + "p", + { className: "org-summary" }, + portalData.org.type, + " operating from ", + headquarters, + ". Treasury, fleet status, inventory, and roster management are surfaced here first.", + ), + h( + "div", + { className: "org-meta-row" }, + h( + "div", + { className: "org-meta-item" }, + h( + "span", + { className: "org-meta-label" }, + "Director", + ), + h( + "span", + { className: "org-meta-value" }, + actions.formatDisplayName(portalData.org.owner), + ), + ), + h( + "div", + { className: "org-meta-item" }, + h( + "span", + { className: "org-meta-label" }, + "Active Members", + ), + h( + "span", + { className: "org-meta-value" }, + `${store.getMembers().length} total`, + ), + ), + h( + "div", + { className: "org-meta-item" }, + h( + "span", + { className: "org-meta-label" }, + "Fleet Readiness", + ), + h( + "span", + { className: "org-meta-value" }, + readiness === null ? "N/A" : `${readiness}%`, + ), + ), + ), + ), + h( + "div", + { className: "org-metric-grid" }, + MetricCard( + "Org Funds", + actions.formatCurrency(store.getFunds()), + "Organization treasury balance", + ), + MetricCard( + "Reputation", + portalData.reputation, + "Organization standing", + ), + MetricCard( + "Asset Lines", + portalData.assets.length, + "Tracked supply and equipment entries", + ), + MetricCard( + "Fleet Vehicles", + portalData.fleet.length, + "Tracked air, ground, and naval vehicles", + ), + ), + ), + }); + }; +})(); diff --git a/arma/ui/apps/components/portal/simpleStat.js b/arma/ui/apps/components/portal/simpleStat.js new file mode 100644 index 0000000..eceb6f6 --- /dev/null +++ b/arma/ui/apps/components/portal/simpleStat.js @@ -0,0 +1,39 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const scopeAttr = "data-ui-simple-stat"; + const scopeSelector = `[${scopeAttr}]`; + const simpleStatCss = ` +${scopeSelector} { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 90px; +} + +${scopeSelector} .org-simple-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +${scopeSelector} .org-simple-value { + font-size: 0.95rem; + color: var(--text-main); +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.SimpleStat = function SimpleStat(label, value) { + ensureScopedStyle("portal-simple-stat", simpleStatCss); + + return h( + "div", + { className: "org-simple-stat", [scopeAttr]: "" }, + h("span", { className: "org-simple-label" }, label), + h("strong", { className: "org-simple-value" }, value), + ); + }; +})(); diff --git a/arma/ui/apps/components/portal/treasuryCard.js b/arma/ui/apps/components/portal/treasuryCard.js new file mode 100644 index 0000000..b43549b --- /dev/null +++ b/arma/ui/apps/components/portal/treasuryCard.js @@ -0,0 +1,430 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle, createSignal } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const store = OrgPortal.store; + const permissions = OrgPortal.permissions; + const actions = OrgPortal.actions; + const scopeAttr = "data-ui-treasury-card"; + const scopeSelector = `[${scopeAttr}]`; + const [getTreasuryTab, setTreasuryTab] = createSignal("overview"); + const [getTreasuryMenuOpen, setTreasuryMenuOpen] = createSignal(false); + const treasuryCardCss = ` +${scopeSelector} .org-treasury-menu { + position: relative; +} + +${scopeSelector} .org-menu-btn { + width: 2.75rem; + height: 2.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid var(--border); + background: #f8fafc; + color: var(--text-muted); +} + +${scopeSelector} .org-menu-btn:hover { + color: var(--primary-hover); + border-color: rgb(148 163 184 / 0.65); +} + +${scopeSelector} .org-menu-btn svg { + width: 1.1rem; + height: 1.1rem; +} + +${scopeSelector} .org-menu-dropdown { + position: absolute; + top: calc(100% + 0.6rem); + right: 0; + min-width: 10.5rem; + padding: 0.45rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fff; + box-shadow: 0 12px 28px rgb(15 23 42 / 0.12); + display: flex; + flex-direction: column; + gap: 0.35rem; + z-index: 5; +} + +${scopeSelector} .org-menu-option + .org-menu-option { + margin-left: 0; +} + +${scopeSelector} .org-menu-option { + width: 100%; + justify-content: flex-start; + background: transparent; + color: var(--text-main); + border: 1px solid transparent; +} + +${scopeSelector} .org-menu-option:hover { + background: #f8fafc; + border-color: rgb(148 163 184 / 0.35); +} + +${scopeSelector} .org-menu-option.is-active { + background: rgb(226 232 240 / 0.7); + color: var(--primary-hover); + border-color: rgb(148 163 184 / 0.35); +} + +${scopeSelector} .org-finance-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +${scopeSelector} .org-finance-meta > div { + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +${scopeSelector} .org-meta-label { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +${scopeSelector} .org-action-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; +} + +${scopeSelector} .org-action-grid button + button { + margin-left: 0; +} + +${scopeSelector} .org-action-grid button { + width: 100%; +} + +${scopeSelector} .org-access-note { + margin: 0 0 1rem; + color: var(--text-muted); + font-size: 0.95rem; +} + +${scopeSelector} .org-credit-summary { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.85rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-credit-summary strong { + font-size: 1rem; +} + +${scopeSelector} .org-credit-summary span:last-child { + font-size: 0.92rem; + line-height: 1.45; +} + +${scopeSelector} .org-credit-lines-list { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +${scopeSelector} .org-credit-line-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; +} + +${scopeSelector} .org-credit-line-row:nth-child(even) { + background: linear-gradient(180deg, rgb(248 250 252) 0%, rgb(241 245 249) 100%); + border-color: rgb(148 163 184 / 0.45); +} + +${scopeSelector} .org-credit-line-member { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +${scopeSelector} .org-credit-line-label { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); +} + +${scopeSelector} .org-credit-line-empty { + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #f8fafc; + color: var(--text-muted); +} + +@media (max-width: 960px) { + ${scopeSelector} .org-finance-meta { + grid-template-columns: 1fr; + } + + ${scopeSelector} .org-credit-line-row { + flex-direction: column; + align-items: flex-start; + } +} +`; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.TreasuryCard = function TreasuryCard() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + const creditLines = store.getCreditLines(); + const allowTreasuryActions = permissions.canManageTreasury(); + const activeTab = getTreasuryTab(); + const isMenuOpen = getTreasuryMenuOpen(); + const activeCreditLabel = + creditLines.length === 1 + ? "1 active credit line" + : `${creditLines.length} active credit lines`; + ensureScopedStyle("portal-treasury-card", treasuryCardCss); + + return PanelCard({ + className: "org-span-5", + title: "Treasury", + subtitle: "Organization funds, reputation, and member payouts.", + headerExtras: h( + "div", + { className: "org-treasury-menu" }, + h( + "button", + { + type: "button", + className: "org-menu-btn", + title: "Treasury views", + "aria-label": "Treasury views", + onClick: () => setTreasuryMenuOpen((open) => !open), + }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + "aria-hidden": "true", + }, + h("line", { x1: "4", y1: "7", x2: "20", y2: "7" }), + h("line", { x1: "4", y1: "12", x2: "20", y2: "12" }), + h("line", { x1: "4", y1: "17", x2: "20", y2: "17" }), + ), + ), + isMenuOpen + ? h( + "div", + { className: "org-menu-dropdown" }, + h( + "button", + { + type: "button", + className: + activeTab === "overview" + ? "org-menu-option is-active" + : "org-menu-option", + onClick: () => { + setTreasuryTab("overview"); + setTreasuryMenuOpen(false); + }, + }, + "Overview", + ), + h( + "button", + { + type: "button", + className: + activeTab === "credit" + ? "org-menu-option is-active" + : "org-menu-option", + onClick: () => { + setTreasuryTab("credit"); + setTreasuryMenuOpen(false); + }, + }, + "Credit Lines", + ), + ) + : null, + ), + rootProps: { [scopeAttr]: "" }, + body: h( + "div", + null, + activeTab === "credit" + ? creditLines.length > 0 + ? h( + "div", + { className: "org-credit-lines-list" }, + ...creditLines.map((line) => + h( + "article", + { className: "org-credit-line-row" }, + h( + "div", + { + className: + "org-credit-line-member", + }, + h( + "span", + { + className: + "org-credit-line-label", + }, + "Member", + ), + h("strong", null, line.member), + ), + h( + "div", + { + className: + "org-credit-line-member", + }, + h( + "span", + { + className: + "org-credit-line-label", + }, + "Amount", + ), + h( + "strong", + null, + actions.formatCurrency( + line.amount, + ), + ), + ), + ), + ), + ) + : h( + "div", + { className: "org-credit-line-empty" }, + "No active credit lines.", + ) + : h( + "div", + null, + h( + "div", + { className: "org-finance-meta" }, + h( + "div", + null, + h( + "span", + { className: "org-meta-label" }, + "Funds", + ), + h( + "strong", + null, + actions.formatCurrency(store.getFunds()), + ), + ), + h( + "div", + null, + h( + "span", + { className: "org-meta-label" }, + "Reputation", + ), + h("strong", null, `${portalData.reputation}`), + ), + ), + allowTreasuryActions + ? h( + "div", + { className: "org-action-grid" }, + h( + "button", + { + type: "button", + onClick: () => + actions.openModal("payroll"), + }, + "Run Payroll", + ), + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => + actions.openModal("transfer"), + }, + "Send Funds", + ), + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => + actions.openModal("credit"), + }, + "Credit Line", + ), + ) + : h( + "p", + { className: "org-access-note" }, + "Only the organization leader or CEO can manage treasury actions.", + ), + h( + "div", + { className: "org-credit-summary" }, + h( + "span", + { className: "org-meta-label" }, + "Credit Line Status", + ), + h("strong", null, activeCreditLabel), + h( + "span", + null, + creditLines.length > 0 + ? "Open the Credit Lines tab to review assigned members and amounts." + : "Assign a credit line to create the first approved member limit.", + ), + ), + ), + ), + }); + }; +})(); diff --git a/arma/ui/apps/controls.css b/arma/ui/apps/controls.css new file mode 100644 index 0000000..5ed9592 --- /dev/null +++ b/arma/ui/apps/controls.css @@ -0,0 +1,33 @@ +.org-secondary-btn { + background: var(--bg-surface); + color: var(--text-main); + border: 1px solid var(--border); + + &:hover { + background: var(--bg-surface-hover); + color: var(--text-main); + } +} + +.org-danger-btn { + background: #7f1d1d; + color: #fef2f2; + + &:hover { + background: #991b1b; + } +} + +.org-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + padding: 0; +} + +.org-icon { + width: 1rem; + height: 1rem; +} diff --git a/arma/ui/apps/hero.css b/arma/ui/apps/hero.css new file mode 100644 index 0000000..eaafcae --- /dev/null +++ b/arma/ui/apps/hero.css @@ -0,0 +1,41 @@ +.org-page-header { + text-align: left; + margin-bottom: 0; +} + +.org-page-heading { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.org-page-kicker { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-muted); + font-weight: 600; +} + +.org-page-title { + margin: 0; +} + +.org-page-subtitle { + font-size: 0.9rem; + color: var(--text-muted); + margin: 0; +} + +.org-page-meta { + font-size: 0.75rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +@media (max-width: 960px) { + .org-page-heading { + gap: 0.3rem; + } +} diff --git a/arma/ui/apps/logic/portalActions.js b/arma/ui/apps/logic/portalActions.js new file mode 100644 index 0000000..bb9e9d1 --- /dev/null +++ b/arma/ui/apps/logic/portalActions.js @@ -0,0 +1,318 @@ +(function () { + const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); + + SharedLogic.createPortalActions = function createPortalActions({ + portalData, + store, + permissions, + registryStore, + }) { + class OrgPortalActions { + constructor() { + this.treasuryNoticeTimer = null; + } + + formatCurrency(value) { + return "$" + value.toLocaleString(); + } + + formatVehicleType(type) { + if (!type) { + return ""; + } + + return type.charAt(0).toUpperCase() + type.slice(1); + } + + formatAssetType(type) { + if (!type) { + return ""; + } + + return type.charAt(0).toUpperCase() + type.slice(1); + } + + formatDisplayName(value) { + if (!value) { + return ""; + } + + return String(value) + .trim() + .split(/\s+/) + .map((part) => { + if (!part) { + return ""; + } + + return ( + part.charAt(0).toUpperCase() + + part.slice(1).toLowerCase() + ); + }) + .join(" "); + } + + getAssetReadiness() { + if (portalData.fleet.length === 0) { + return null; + } + + const total = portalData.fleet.reduce( + (sum, unit) => sum + (100 - parseInt(unit.damage, 10)), + 0, + ); + return Math.round(total / portalData.fleet.length); + } + + showTreasuryNotice(type, text) { + store.setTreasuryNotice({ type, text }); + + if (this.treasuryNoticeTimer) { + clearTimeout(this.treasuryNoticeTimer); + } + + this.treasuryNoticeTimer = setTimeout(() => { + store.setTreasuryNotice({ type: "", text: "" }); + this.treasuryNoticeTimer = null; + }, 3500); + } + + parseAmount(value) { + const amount = Number(value); + return Number.isFinite(amount) ? Math.round(amount) : 0; + } + + getInputValue(id) { + const el = document.getElementById(id); + return el ? el.value : ""; + } + + isOwnerMember(memberName) { + return ( + String(memberName || "") + .trim() + .toLowerCase() === + String(portalData.org.owner || "") + .trim() + .toLowerCase() + ); + } + + closePortal() { + if ( + typeof A3API !== "undefined" && + typeof A3API.SendAlert === "function" + ) { + A3API.SendAlert( + JSON.stringify({ + event: "org::close", + data: {}, + }), + ); + return; + } + + if (registryStore) { + registryStore.setView("home"); + } + } + + openModal(type) { + if ( + (type === "payroll" || + type === "transfer" || + type === "credit") && + !permissions.canManageTreasury() + ) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return; + } + + if (type === "disband" && !permissions.canDisbandOrg()) { + return; + } + + store.setModal({ type }); + } + + closeModal() { + store.setModal(null); + } + + removeMember(memberName) { + if (!permissions.canManageMembers()) { + return false; + } + + if (this.isOwnerMember(memberName)) { + return false; + } + + store.setMembers((currentMembers) => + currentMembers.filter( + (member) => member.name !== memberName, + ), + ); + store.setCreditLines((currentLines) => + currentLines.filter((line) => line.member !== memberName), + ); + return true; + } + + disbandOrganization() { + if (!permissions.canDisbandOrg()) { + return false; + } + + store.setOrgDisbanded(true); + this.closeModal(); + return true; + } + + runPayroll(amountPerMember) { + if (!permissions.canManageTreasury()) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return false; + } + + const members = store.getMembers(); + const funds = store.getFunds(); + + if (members.length === 0) { + this.showTreasuryNotice( + "error", + "No members available for payroll.", + ); + return false; + } + + if (amountPerMember <= 0) { + this.showTreasuryNotice( + "error", + "Enter a valid payroll amount.", + ); + return false; + } + + const total = amountPerMember * members.length; + if (total > funds) { + this.showTreasuryNotice( + "error", + "Insufficient org funds for payroll.", + ); + return false; + } + + store.setFunds(funds - total); + this.showTreasuryNotice( + "success", + `Payroll sent to ${members.length} members for ${this.formatCurrency(total)}.`, + ); + return true; + } + + sendFundsToMember(memberName, amount) { + if (!permissions.canManageTreasury()) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return false; + } + + const funds = store.getFunds(); + + if (!memberName) { + this.showTreasuryNotice( + "error", + "Select a member to receive funds.", + ); + return false; + } + + if (amount <= 0) { + this.showTreasuryNotice( + "error", + "Enter a valid transfer amount.", + ); + return false; + } + + if (amount > funds) { + this.showTreasuryNotice( + "error", + "Insufficient org funds for this transfer.", + ); + return false; + } + + store.setFunds(funds - amount); + this.showTreasuryNotice( + "success", + `${this.formatCurrency(amount)} sent to ${memberName}.`, + ); + return true; + } + + grantCreditLine(memberName, amount) { + if (!permissions.canManageTreasury()) { + this.showTreasuryNotice( + "error", + "Only the organization leader or CEO can manage treasury actions.", + ); + return false; + } + + if (!memberName) { + this.showTreasuryNotice( + "error", + "Select a member for the credit line.", + ); + return false; + } + + if (amount <= 0) { + this.showTreasuryNotice( + "error", + "Enter a valid credit line amount.", + ); + return false; + } + + store.setCreditLines((currentLines) => { + const existingIndex = currentLines.findIndex( + (line) => line.member === memberName, + ); + if (existingIndex === -1) { + return [ + ...currentLines, + { member: memberName, amount }, + ]; + } + + const updatedLines = [...currentLines]; + updatedLines[existingIndex] = { + member: memberName, + amount, + }; + return updatedLines; + }); + + this.showTreasuryNotice( + "success", + `Credit line of ${this.formatCurrency(amount)} assigned to ${memberName}.`, + ); + return true; + } + } + + return new OrgPortalActions(); + }; +})(); diff --git a/arma/ui/apps/logic/portalPermissions.js b/arma/ui/apps/logic/portalPermissions.js new file mode 100644 index 0000000..b454363 --- /dev/null +++ b/arma/ui/apps/logic/portalPermissions.js @@ -0,0 +1,75 @@ +(function () { + const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); + + SharedLogic.createPortalPermissions = function createPortalPermissions({ + portalData, + session, + }) { + class OrgPortalPermissions { + getNormalizedRole() { + return String(session.role || "") + .trim() + .toUpperCase(); + } + + isDefaultOrg() { + return ( + portalData.org.isDefault === true || + String(portalData.org.tag || "") + .trim() + .toUpperCase() === "DEFAULT" + ); + } + + isOrgOwner() { + const ownerUid = String( + portalData.org.ownerUid || portalData.org.owner || "", + ) + .trim() + .toLowerCase(); + const actorUid = String(session.actorUid || "") + .trim() + .toLowerCase(); + + if (ownerUid && actorUid) { + return actorUid === ownerUid; + } + + return ( + String(session.actorName || "") + .trim() + .toLowerCase() === + String(portalData.org.owner || "") + .trim() + .toLowerCase() + ); + } + + isSessionCeo() { + return session.ceo === true; + } + + isOrgLeaderOrCeo() { + return ( + this.isOrgOwner() || + this.getNormalizedRole() === "LEADER" || + (this.isDefaultOrg() && this.isSessionCeo()) + ); + } + + canManageMembers() { + return this.isOrgLeaderOrCeo(); + } + + canManageTreasury() { + return this.isOrgLeaderOrCeo(); + } + + canDisbandOrg() { + return this.isOrgLeaderOrCeo(); + } + } + + return new OrgPortalPermissions(); + }; +})(); diff --git a/arma/ui/apps/logic/portalStore.js b/arma/ui/apps/logic/portalStore.js new file mode 100644 index 0000000..99b8a19 --- /dev/null +++ b/arma/ui/apps/logic/portalStore.js @@ -0,0 +1,38 @@ +(function () { + const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); + + SharedLogic.createPortalStore = function createPortalStore({ + createSignal, + portalData, + }) { + class OrgPortalStore { + constructor() { + [this.getFunds, this.setFunds] = createSignal(portalData.funds); + [this.getMembers, this.setMembers] = createSignal([ + ...portalData.members, + ]); + [this.getCreditLines, this.setCreditLines] = createSignal([]); + [this.getTreasuryNotice, this.setTreasuryNotice] = createSignal( + { + type: "", + text: "", + }, + ); + [this.getModal, this.setModal] = createSignal(null); + [this.getOrgDisbanded, this.setOrgDisbanded] = + createSignal(false); + } + + hydrateFromPayload(payload) { + this.setFunds(payload.portalData.funds || 0); + this.setMembers([...(payload.portalData.members || [])]); + this.setCreditLines([]); + this.setTreasuryNotice({ type: "", text: "" }); + this.setModal(null); + this.setOrgDisbanded(false); + } + } + + return new OrgPortalStore(); + }; +})(); diff --git a/arma/ui/apps/logic/registryStore.js b/arma/ui/apps/logic/registryStore.js new file mode 100644 index 0000000..e7db429 --- /dev/null +++ b/arma/ui/apps/logic/registryStore.js @@ -0,0 +1,69 @@ +(function () { + const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); + + SharedLogic.createRegistryStore = function createRegistryStore({ + createSignal, + onHydratePortal, + }) { + class RegistryStore { + constructor() { + [this.getView, this.setView] = createSignal("home"); + [this.getIsAuthenticating, this.setIsAuthenticating] = + createSignal(false); + [this.getLoginError, this.setLoginError] = createSignal(""); + [this.getIsCreating, this.setIsCreating] = createSignal(false); + [this.getCreateError, this.setCreateError] = createSignal(""); + } + + startLogin() { + this.setLoginError(""); + this.setIsAuthenticating(true); + } + + startCreate() { + this.setCreateError(""); + this.setIsCreating(true); + } + + failLogin(message) { + this.setIsAuthenticating(false); + this.setLoginError(message || "Authentication failed."); + } + + failCreate(message) { + this.setIsCreating(false); + this.setCreateError( + message || "Organization registration failed.", + ); + } + + hydratePortal(payload) { + return Boolean(onHydratePortal && onHydratePortal(payload)); + } + + completeLogin(payload) { + if (!this.hydratePortal(payload)) { + this.failLogin("Login response was missing portal data."); + return; + } + + this.setLoginError(""); + this.setIsAuthenticating(false); + } + + completeCreate(payload) { + if (!this.hydratePortal(payload)) { + this.failCreate( + "Organization registration response was missing portal data.", + ); + return; + } + + this.setCreateError(""); + this.setIsCreating(false); + } + } + + return new RegistryStore(); + }; +})(); diff --git a/arma/ui/apps/main/bootstrap.js b/arma/ui/apps/main/bootstrap.js new file mode 100644 index 0000000..663a0b3 --- /dev/null +++ b/arma/ui/apps/main/bootstrap.js @@ -0,0 +1,6 @@ +/** + * Registry app bootstrap + */ + +const root = document.getElementById("app"); +window.RegistryApp.runtime.render(window.RegistryApp.components.App, root); diff --git a/arma/ui/apps/main/bridge.js b/arma/ui/apps/main/bridge.js new file mode 100644 index 0000000..837ec0c --- /dev/null +++ b/arma/ui/apps/main/bridge.js @@ -0,0 +1,123 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const store = RegistryApp.store; + + function sendEvent(event, data) { + if ( + typeof A3API !== "undefined" && + typeof A3API.SendAlert === "function" + ) { + A3API.SendAlert( + JSON.stringify({ + event, + data, + }), + ); + return true; + } + + return false; + } + + function getMockPayload() { + const OrgPortal = window.OrgPortal; + return { + session: JSON.parse(JSON.stringify(OrgPortal.data.session)), + portalData: JSON.parse(JSON.stringify(OrgPortal.data.portalData)), + }; + } + + function requestLogin(credentials) { + store.startLogin(); + + const sent = sendEvent("org::login::request", credentials); + if (sent) { + return; + } + + window.setTimeout(() => { + store.completeLogin(getMockPayload()); + }, 350); + } + + function requestCreateOrg(registration) { + store.startCreate(); + + const sent = sendEvent("org::create::request", registration); + if (sent) { + return; + } + + window.setTimeout(() => { + const orgName = String(registration.orgName || "").trim(); + if (!orgName) { + store.failCreate("Enter an organization name."); + return; + } + + const payload = getMockPayload(); + payload.portalData.org.name = orgName; + payload.portalData.org.tag = String(Date.now()).slice(-10); + payload.portalData.org.owner = + payload.session.actorName || "Unknown"; + payload.portalData.org.ownerUid = payload.session.actorUid || ""; + payload.portalData.org.isDefault = false; + payload.session.role = "Leader"; + payload.session.ceo = false; + payload.portalData.members = [ + { name: payload.session.actorName || "Unknown" }, + ]; + + store.completeCreate(payload); + }, 350); + } + + function receive(eventOrPayload, data = {}) { + const event = + typeof eventOrPayload === "object" && eventOrPayload !== null + ? eventOrPayload.event + : eventOrPayload; + const payloadData = + typeof eventOrPayload === "object" && eventOrPayload !== null + ? eventOrPayload.data || {} + : data; + + if (event === "org::login::success") { + store.completeLogin(payloadData); + return; + } + + if (event === "org::login::failure") { + store.failLogin(payloadData.message || "Authentication failed."); + return; + } + + if (event === "org::create::success") { + store.completeCreate(payloadData); + return; + } + + if (event === "org::create::failure") { + store.failCreate( + payloadData.message || "Organization registration failed.", + ); + } + } + + RegistryApp.bridge = { + requestLogin, + requestCreateOrg, + receive, + sendEvent, + }; + + window.OrgUIBridge = { + requestLogin, + requestCreateOrg, + receive, + receiveLoginSuccess: (data) => receive("org::login::success", data), + receiveLoginFailure: (data) => receive("org::login::failure", data), + receiveCreateSuccess: (data) => receive("org::create::success", data), + receiveCreateFailure: (data) => receive("org::create::failure", data), + }; +})(); diff --git a/arma/ui/apps/main/index.html b/arma/ui/apps/main/index.html new file mode 100644 index 0000000..a699380 --- /dev/null +++ b/arma/ui/apps/main/index.html @@ -0,0 +1,49 @@ + + + + + + ORBIS - Global Organization Network + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/arma/ui/apps/main/state.js b/arma/ui/apps/main/state.js new file mode 100644 index 0000000..c4c0106 --- /dev/null +++ b/arma/ui/apps/main/state.js @@ -0,0 +1,23 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { createSignal } = RegistryApp.runtime; + const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); + + RegistryApp.store = SharedLogic.createRegistryStore({ + createSignal, + onHydratePortal(payload) { + const OrgPortal = window.OrgPortal; + const portalData = payload?.portalData; + const session = payload?.session; + + if (!OrgPortal || !portalData || !session) { + return false; + } + + OrgPortal.data.applyLoginPayload(payload); + OrgPortal.store.hydrateFromPayload(payload); + RegistryApp.store.setView("portal"); + return true; + }, + }); +})(); diff --git a/arma/ui/apps/portal/actions.js b/arma/ui/apps/portal/actions.js new file mode 100644 index 0000000..b8b3f5e --- /dev/null +++ b/arma/ui/apps/portal/actions.js @@ -0,0 +1,15 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { portalData } = OrgPortal.data; + const store = OrgPortal.store; + const permissions = OrgPortal.permissions; + const registryStore = window.RegistryApp.store; + const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); + + OrgPortal.actions = SharedLogic.createPortalActions({ + portalData, + store, + permissions, + registryStore, + }); +})(); diff --git a/arma/ui/apps/portal/data.js b/arma/ui/apps/portal/data.js new file mode 100644 index 0000000..66208f0 --- /dev/null +++ b/arma/ui/apps/portal/data.js @@ -0,0 +1,172 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const staticOrgProfile = { + type: "Organization", + status: "Operational", + headquarters: "ArmA Verse", + }; + + function cloneValue(value) { + return JSON.parse(JSON.stringify(value)); + } + + function replaceObject(target, source) { + Object.keys(target).forEach((key) => delete target[key]); + Object.assign(target, cloneValue(source)); + } + + function replaceArray(target, source) { + target.splice(0, target.length, ...cloneValue(source)); + } + + OrgPortal.data = { + portalData: { + org: Object.assign( + { + name: "Black Rifle Company", + tag: "BRC-0160566824", + owner: "Jacob Schmidt", + ownerUid: "uid-jacob-schmidt", + isDefault: false, + }, + staticOrgProfile, + ), + funds: 482750, + reputation: 72, + members: [ + { name: "Jacob Schmidt" }, + { name: "Mara Velez" }, + { name: "Rylan Cross" }, + { name: "Noah Briggs" }, + { name: "Elena Price" }, + { name: "Isaac Rowe" }, + { name: "Talia Boone" }, + { name: "Cade Mercer" }, + ], + fleet: [ + { + name: "UH-80 Ghost Hawk", + type: "helicopter", + status: "Ready", + damage: "16%", + }, + { + name: "MH-9 Hummingbird", + type: "helicopter", + status: "Ready", + damage: "8%", + }, + { + name: "M-ATV Patrol 1", + type: "car", + status: "Fielded", + damage: "24%", + }, + { + name: "M2A1 Slammer", + type: "armor", + status: "Ready", + damage: "11%", + }, + { + name: "RHIB Patrol Boat", + type: "naval", + status: "Repairing", + damage: "32%", + }, + ], + assets: [ + { name: "First Aid Kits", type: "items", quantity: "36" }, + { name: "MX 6.5 mm Rifles", type: "weapons", quantity: "18" }, + { + name: "6.5 mm Magazines", + type: "magazines", + quantity: "120", + }, + { + name: "Carryall Backpacks", + type: "backpacks", + quantity: "24", + }, + ], + activity: [ + { + time: "08:20", + text: "Treasury cleared contractor payment for northern route escort.", + }, + { + time: "07:45", + text: "Viper Flight completed readiness checks on all rotary assets.", + }, + { + time: "07:10", + text: "New recruit Cade Mercer accepted into ground training roster.", + }, + { + time: "06:30", + text: "North Depot inventory count pushed reserve ratio above target.", + }, + ], + roadmap: [ + { + name: "Contracts Board", + status: "Planned", + detail: "Track payouts, assignments, and claim approvals.", + }, + { + name: "Diplomacy", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + { + name: "Logistics Queue", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + { + name: "Permissions", + status: "Future Review", + detail: "Possible future module pending a full design and scope review.", + }, + ], + }, + session: { + actorName: "Jacob Schmidt", + actorUid: "uid-jacob-schmidt", + role: "Leader", + ceo: false, + }, + applyLoginPayload(payload) { + replaceObject( + this.portalData.org, + Object.assign( + {}, + payload.portalData.org || {}, + staticOrgProfile, + ), + ); + this.portalData.funds = payload.portalData.funds || 0; + this.portalData.reputation = payload.portalData.reputation || 0; + + replaceArray( + this.portalData.members, + payload.portalData.members || [], + ); + replaceArray(this.portalData.fleet, payload.portalData.fleet || []); + replaceArray( + this.portalData.assets, + payload.portalData.assets || [], + ); + replaceArray( + this.portalData.activity, + payload.portalData.activity || [], + ); + replaceArray( + this.portalData.roadmap, + payload.portalData.roadmap || [], + ); + + replaceObject(this.session, payload.session || {}); + }, + }; +})(); diff --git a/arma/ui/apps/portal/permissions.js b/arma/ui/apps/portal/permissions.js new file mode 100644 index 0000000..b25ec78 --- /dev/null +++ b/arma/ui/apps/portal/permissions.js @@ -0,0 +1,10 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { portalData, session } = OrgPortal.data; + const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); + + OrgPortal.permissions = SharedLogic.createPortalPermissions({ + portalData, + session, + }); +})(); diff --git a/arma/ui/apps/portal/store.js b/arma/ui/apps/portal/store.js new file mode 100644 index 0000000..5f2711e --- /dev/null +++ b/arma/ui/apps/portal/store.js @@ -0,0 +1,11 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { createSignal } = window.RegistryApp.runtime; + const { portalData } = OrgPortal.data; + const SharedLogic = (window.SharedLogic = window.SharedLogic || {}); + + OrgPortal.store = SharedLogic.createPortalStore({ + createSignal, + portalData, + }); +})(); diff --git a/arma/ui/apps/runtime.js b/arma/ui/apps/runtime.js new file mode 100644 index 0000000..fce44e9 --- /dev/null +++ b/arma/ui/apps/runtime.js @@ -0,0 +1,122 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + + const SVG_NS = "http://www.w3.org/2000/svg"; + const SVG_TAGS = new Set([ + "svg", + "path", + "circle", + "rect", + "line", + "polyline", + "polygon", + "g", + "defs", + "use", + "text", + "tspan", + "clipPath", + "mask", + ]); + + function h(tag, props = {}, ...children) { + const isSvg = SVG_TAGS.has(tag); + const el = isSvg + ? document.createElementNS(SVG_NS, tag) + : document.createElement(tag); + + if (props) { + Object.entries(props).forEach(([key, value]) => { + if (key.startsWith("on") && typeof value === "function") { + el.addEventListener(key.substring(2).toLowerCase(), value); + } else if (key === "className") { + if (isSvg) { + el.setAttribute("class", value); + } else { + el.className = value; + } + } else if (key === "style" && typeof value === "object") { + Object.assign(el.style, value); + } else if (typeof value === "boolean") { + if (value) { + el.setAttribute(key, ""); + } else { + el.removeAttribute(key); + } + } else if (value === null || value === undefined) { + el.removeAttribute(key); + } else { + el.setAttribute(key, value); + } + }); + } + + children.forEach((child) => { + if (typeof child === "string" || typeof child === "number") { + el.appendChild(document.createTextNode(child)); + } else if (child instanceof Node) { + el.appendChild(child); + } else if (Array.isArray(child)) { + child.forEach((c) => el.appendChild(c)); + } + }); + + return el; + } + + let rootContainer = null; + let rootComponent = null; + const injectedStyles = new Set(); + + function render(component, container) { + rootContainer = container; + rootComponent = component; + rerender(); + } + + function rerender() { + if (!rootContainer || !rootComponent) { + return; + } + + rootContainer.innerHTML = ""; + rootContainer.appendChild(rootComponent()); + } + + function ensureScopedStyle(id, cssText) { + if (!id || !cssText || injectedStyles.has(id)) { + return; + } + + const style = document.createElement("style"); + style.setAttribute("data-ui-style", id); + style.textContent = cssText; + document.head.appendChild(style); + injectedStyles.add(id); + } + + function createSignal(initialValue) { + let value = initialValue; + + const getValue = () => value; + const setValue = (newValue) => { + value = typeof newValue === "function" ? newValue(value) : newValue; + rerender(); + }; + + return [getValue, setValue]; + } + + const runtime = { + h, + render, + createSignal, + ensureScopedStyle, + rerender, + }; + + RegistryApp.runtime = runtime; + OrgPortal.runtime = runtime; + window.AppRuntime = runtime; +})(); diff --git a/arma/ui/apps/views/DisbandedView.js b/arma/ui/apps/views/DisbandedView.js new file mode 100644 index 0000000..01659e0 --- /dev/null +++ b/arma/ui/apps/views/DisbandedView.js @@ -0,0 +1,36 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h } = OrgPortal.runtime; + const { portalData } = OrgPortal.data; + const registryStore = window.RegistryApp.store; + + OrgPortal.componentFns = OrgPortal.componentFns || {}; + + OrgPortal.componentFns.DisbandedView = function DisbandedView() { + const PanelCard = window.SharedUI.componentFns.PanelCard; + + return PanelCard({ + className: "org-span-12 org-empty-state", + eyebrow: "Organization Removed", + title: portalData.org.name, + body: h( + "div", + null, + h( + "p", + { className: "org-summary" }, + "This organization has been disbanded. Member access, assets, and fleet management are no longer available from this portal preview.", + ), + h( + "button", + { + type: "button", + className: "org-secondary-btn", + onClick: () => registryStore.setView("home"), + }, + "Return to Registry", + ), + ), + }); + }; +})(); diff --git a/arma/ui/apps/views/HomeView.js b/arma/ui/apps/views/HomeView.js new file mode 100644 index 0000000..be9a07b --- /dev/null +++ b/arma/ui/apps/views/HomeView.js @@ -0,0 +1,97 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const store = RegistryApp.store; + const bridge = RegistryApp.bridge; + const scopeAttr = "data-ui-home-view"; + const scopeSelector = `[${scopeAttr}]`; + const homeViewCss = ` +${scopeSelector} { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin-bottom: 2rem; +} + +${scopeSelector} .home-span-full { + grid-column: span 2; +} + +${scopeSelector} .home-feedback { + padding: 0.85rem 1rem; + border-radius: var(--radius); + font-size: 0.92rem; + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; +} + +@media (max-width: 960px) { + ${scopeSelector} { + grid-template-columns: 1fr; + } + + ${scopeSelector} .home-span-full { + grid-column: span 1; + } +} +`; + + RegistryApp.componentFns = RegistryApp.componentFns || {}; + + RegistryApp.componentFns.HomeView = function HomeView() { + const isAuthenticating = store.getIsAuthenticating(); + const loginError = store.getLoginError(); + ensureScopedStyle("main-home-view", homeViewCss); + + return h( + "div", + { className: "content", [scopeAttr]: "" }, + h( + "div", + { className: "card" }, + h("h2", null, "Create Organization"), + h( + "p", + null, + "Establish your Task Force, PMC, or Milsim unit with the Global Organization Network. Receive your official unit designator and TO&E authorization instantly.", + ), + h( + "button", + { onClick: () => store.setView("create") }, + "Register", + ), + ), + h( + "div", + { className: "card" }, + h("h2", null, "Organization Portal"), + h( + "p", + null, + "Access your unit dashboard to modify rosters, adjust active deployments, and submit after-action reports through the secure field uplink.", + ), + loginError + ? h("div", { className: "home-feedback" }, loginError) + : null, + h( + "button", + { + disabled: isAuthenticating, + onClick: () => { + if (!bridge) { + store.failLogin( + "Login bridge is not available.", + ); + return; + } + + bridge.requestLogin({}); + }, + }, + isAuthenticating ? "Opening Portal..." : "Login", + ), + ), + ); + }; +})(); diff --git a/arma/ui/apps/views/PortalView.js b/arma/ui/apps/views/PortalView.js new file mode 100644 index 0000000..8bf61b8 --- /dev/null +++ b/arma/ui/apps/views/PortalView.js @@ -0,0 +1,206 @@ +(function () { + const OrgPortal = (window.OrgPortal = window.OrgPortal || {}); + const { h, ensureScopedStyle } = OrgPortal.runtime; + const { portalData, session } = OrgPortal.data; + const store = OrgPortal.store; + const portalViewScope = "[data-ui-portal-view]"; + + ensureScopedStyle( + "portal-view", + ` + ${portalViewScope} .org-toast-stack { + position: fixed; + top: 1.5rem; + right: 2rem; + z-index: 20; + display: flex; + flex-direction: column; + gap: 0.75rem; + pointer-events: none; + } + + ${portalViewScope} .org-toast { + max-width: 24rem; + padding: 0.9rem 1rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: #fff; + box-shadow: 0 12px 28px rgb(15 23 42 / 0.14); + font-size: 0.92rem; + pointer-events: auto; + } + + ${portalViewScope} .org-toast.is-success { + background: #ecfdf5; + border-color: #bbf7d0; + color: #166534; + } + + ${portalViewScope} .org-toast.is-error { + background: #fef2f2; + border-color: #fecaca; + color: #991b1b; + } + + ${portalViewScope} .org-dashboard-grid { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 1.5rem; + } + + ${portalViewScope} .org-panel { + margin-bottom: 0; + text-align: left; + } + + ${portalViewScope} .org-scroll-panel { + display: flex; + flex-direction: column; + max-height: 31rem; + overflow: hidden; + } + + ${portalViewScope} .org-span-12 { + grid-column: span 12; + } + + ${portalViewScope} .org-span-7 { + grid-column: span 7; + } + + ${portalViewScope} .org-span-6 { + grid-column: span 6; + } + + ${portalViewScope} .org-span-5 { + grid-column: span 5; + } + + @media (max-width: 960px) { + ${portalViewScope} .org-toast-stack { + top: 1rem; + right: 1rem; + left: 1rem; + } + + ${portalViewScope} .org-toast { + max-width: none; + } + + ${portalViewScope} .org-span-12, + ${portalViewScope} .org-span-7, + ${portalViewScope} .org-span-6, + ${portalViewScope} .org-span-5 { + grid-column: span 12; + } + } + `, + ); + + OrgPortal.components = OrgPortal.components || {}; + + OrgPortal.components.App = function App() { + const Hero = window.SharedUI.componentFns.Hero; + const Footer = window.SharedUI.componentFns.Footer; + const OverviewCard = OrgPortal.componentFns.OverviewCard; + const FleetCard = OrgPortal.componentFns.FleetCard; + const TreasuryCard = OrgPortal.componentFns.TreasuryCard; + const MembersCard = OrgPortal.componentFns.MembersCard; + const AssetsCard = OrgPortal.componentFns.AssetsCard; + const ActivityCard = OrgPortal.componentFns.ActivityCard; + const FutureCard = OrgPortal.componentFns.FutureCard; + const DangerCard = OrgPortal.componentFns.DangerCard; + const ModalLayer = OrgPortal.componentFns.ModalLayer; + const DisbandedView = OrgPortal.componentFns.DisbandedView; + const treasuryNotice = store.getTreasuryNotice(); + const footerSections = [ + { + title: "Organization Controls", + items: [ + "Roster Management", + "Fleet Assignment", + "Treasury Permissions", + "Asset Registry", + ], + }, + { + title: "Planned Extensions", + items: [ + "Contracts Board", + "Diplomacy Layer", + "Procurement Queue", + "Reputation History", + ], + }, + ]; + + if (store.getOrgDisbanded()) { + return h( + "main", + { "data-ui-portal-view": "" }, + h( + "div", + { className: "container" }, + h( + "div", + { className: "org-dashboard-grid" }, + Hero({ + kicker: portalData.org.tag, + title: portalData.org.name, + subtitle: "Player organization command portal", + meta: `${session.actorName} - ${session.role}`, + }), + DisbandedView(), + ), + ), + ModalLayer(), + Footer({ sections: footerSections }), + ); + } + + return h( + "main", + { "data-ui-portal-view": "" }, + treasuryNotice.text + ? h( + "div", + { className: "org-toast-stack" }, + h( + "div", + { + className: + treasuryNotice.type === "error" + ? "org-toast is-error" + : "org-toast is-success", + }, + treasuryNotice.text, + ), + ) + : null, + h( + "div", + { className: "container" }, + h( + "div", + { className: "org-dashboard-grid" }, + Hero({ + kicker: portalData.org.tag, + title: portalData.org.name, + subtitle: "Player organization command portal", + meta: `${session.actorName} - ${session.role}`, + }), + OverviewCard(), + FleetCard(), + TreasuryCard(), + MembersCard(), + AssetsCard(), + ActivityCard(), + FutureCard(), + DangerCard(), + ), + ), + ModalLayer(), + Footer({ sections: footerSections }), + ); + }; +})(); diff --git a/arma/ui/apps/views/RegistrationView.js b/arma/ui/apps/views/RegistrationView.js new file mode 100644 index 0000000..841abf0 --- /dev/null +++ b/arma/ui/apps/views/RegistrationView.js @@ -0,0 +1,342 @@ +(function () { + const RegistryApp = (window.RegistryApp = window.RegistryApp || {}); + const { h, ensureScopedStyle } = RegistryApp.runtime; + const store = RegistryApp.store; + const bridge = RegistryApp.bridge; + const scopeAttr = "data-ui-registration-view"; + const scopeSelector = `[${scopeAttr}]`; + const registrationViewCss = ` +${scopeSelector} { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + align-items: center; + width: 100%; +} + +${scopeSelector} .info-panel { + text-align: left; + padding: 1rem; +} + +${scopeSelector} .create-feature-list { + text-align: left; + margin-top: 1.5rem; + list-style-type: none; + padding: 0; +} + +${scopeSelector} .create-feature-item { + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +${scopeSelector} .create-feature-icon { + width: 1.2rem; + height: 1.2rem; + flex-shrink: 0; +} + +${scopeSelector} .price-tag { + margin-top: 2rem; + padding: 1rem; + background: var(--bg-app); + border-radius: var(--radius); + border: 1px solid var(--border); +} + +${scopeSelector} .price-label { + display: block; + font-size: 0.9rem; + color: var(--text-muted); +} + +${scopeSelector} .price-value { + display: block; + font-size: 2rem; + font-weight: 700; + color: var(--primary); +} + +${scopeSelector} .form-panel { + margin: 0; +} + +${scopeSelector} .app-form { + display: flex; + flex-direction: column; + gap: 1rem; + text-align: left; +} + +${scopeSelector} .app-form label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-muted); + font-weight: 500; + font-size: 0.9rem; +} + +${scopeSelector} .app-form input, +${scopeSelector} .app-form select { + width: 100%; + padding: 0.75rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-app); + color: var(--text-main); + font-family: inherit; + font-size: 1rem; + box-sizing: border-box; + transition: border-color 0.2s; +} + +${scopeSelector} .app-form input:focus, +${scopeSelector} .app-form select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgb(59 130 246 / 0.1); +} + +${scopeSelector} .form-actions { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; +} + +${scopeSelector} .submit-btn { + width: 100%; +} + +${scopeSelector} .cancel-link { + font-size: 0.9rem; + color: var(--text-muted); + cursor: pointer; + text-decoration: underline; +} + +${scopeSelector} .cancel-link:hover { + color: var(--primary); +} + +${scopeSelector} .form-feedback { + padding: 0.85rem 1rem; + border-radius: var(--radius); + font-size: 0.92rem; +} + +${scopeSelector} .form-feedback.is-error { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; +} + +@media (max-width: 960px) { + ${scopeSelector} { + grid-template-columns: 1fr; + } +} +`; + + RegistryApp.componentFns = RegistryApp.componentFns || {}; + + RegistryApp.componentFns.RegistrationView = function RegistrationView() { + const isCreating = store.getIsCreating(); + const createError = store.getCreateError(); + ensureScopedStyle("main-registration-view", registrationViewCss); + + const handleCreate = () => { + const data = { + orgName: String( + document.getElementById("org-create-name")?.value || "", + ).trim(), + type: String( + document.getElementById("org-create-type")?.value || "", + ), + }; + + if (!bridge || typeof bridge.requestCreateOrg !== "function") { + store.failCreate("Registration bridge is not available."); + return; + } + + bridge.requestCreateOrg(data); + }; + + return h( + "div", + { className: "split-container", [scopeAttr]: "" }, + h( + "div", + { className: "info-panel" }, + h("h2", null, "Registration Details"), + h( + "p", + null, + "Complete the form to add your organization to the Global Organization Registry.", + ), + h( + "ul", + { className: "create-feature-list" }, + h( + "li", + { className: "create-feature-item" }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + className: "create-feature-icon", + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Official Organization Designator", + ), + h( + "li", + { className: "create-feature-item" }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + className: "create-feature-icon", + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Secure Comms Channel", + ), + h( + "li", + { className: "create-feature-item" }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + className: "create-feature-icon", + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "Deployment Roster Access", + ), + h( + "li", + { className: "create-feature-item" }, + h( + "svg", + { + viewBox: "0 0 24 24", + fill: "none", + stroke: "#10b981", + "stroke-width": "2", + "stroke-linecap": "round", + "stroke-linejoin": "round", + className: "create-feature-icon", + }, + h("path", { d: "M20 6L9 17l-5-5" }), + ), + "After-Action Report Tools", + ), + ), + h( + "div", + { className: "price-tag" }, + h("span", { className: "price-label" }, "Registration Fee"), + h("span", { className: "price-value" }, "$50,000"), + ), + ), + h( + "div", + { className: "form-panel card" }, + h("h2", null, "Organization Registration"), + h( + "div", + { className: "app-form" }, + h( + "div", + null, + h("label", null, "Organization Name"), + h("input", { + id: "org-create-name", + type: "text", + placeholder: "e.g. Task Force 141", + }), + ), + h( + "div", + null, + h("label", null, "Organization Type"), + h( + "select", + { id: "org-create-type" }, + h( + "option", + { value: "infantry" }, + "Infantry / Milsim", + ), + h("option", { value: "aviation" }, "Aviation Wing"), + h( + "option", + { value: "pmc" }, + "Private Military Company", + ), + h( + "option", + { value: "support" }, + "Logistics & Support", + ), + ), + ), + h( + "div", + { className: "form-actions" }, + createError + ? h( + "div", + { className: "form-feedback is-error" }, + createError, + ) + : null, + h( + "button", + { + type: "button", + className: "submit-btn", + disabled: isCreating, + onClick: handleCreate, + }, + isCreating + ? "Submitting Registration..." + : "Submit Registration", + ), + h( + "span", + { + className: "cancel-link", + onClick: () => store.setView("home"), + }, + "Cancel / Return to Main", + ), + ), + ), + ), + ); + }; +})(); diff --git a/arma/ui/atm.html b/arma/ui/atm.html new file mode 100644 index 0000000..b6f4444 --- /dev/null +++ b/arma/ui/atm.html @@ -0,0 +1,24 @@ + + + + + + ATM - Global Financial Network + + + + + +
+ + + diff --git a/arma/ui/atm.js b/arma/ui/atm.js new file mode 100644 index 0000000..6286ccb --- /dev/null +++ b/arma/ui/atm.js @@ -0,0 +1,407 @@ +/** + * ATM App - Vanilla JS Kiosk Implementation + */ + +// --- 1. The "Library" Logic (Reused) --- + +function h(tag, props = {}, ...children) { + const el = document.createElement(tag); + if (props) { + Object.entries(props).forEach(([key, value]) => { + if (key.startsWith("on") && typeof value === "function") { + el.addEventListener(key.substring(2).toLowerCase(), value); + } else if (key === "className") { + el.className = value; + } else if (key === "style" && typeof value === "object") { + Object.assign(el.style, value); + } else { + el.setAttribute(key, value); + } + }); + } + children.forEach((child) => { + if (typeof child === "string" || typeof child === "number") { + el.appendChild(document.createTextNode(child)); + } else if (child instanceof Node) { + el.appendChild(child); + } else if (Array.isArray(child)) { + child.forEach((c) => el.appendChild(c)); + } + }); + return el; +} + +let _rootContainer = null; +let _rootComponent = null; + +function render(component, container) { + _rootContainer = container; + _rootComponent = component; + _render(); +} + +function _render() { + _rootContainer.innerHTML = ""; + _rootContainer.appendChild(_rootComponent()); +} + +const createSignal = (initialValue) => { + let _val = initialValue; + const getValue = () => _val; + const setValue = (newValue) => { + _val = typeof newValue === "function" ? newValue(_val) : newValue; + _render(); + }; + return [getValue, setValue]; +}; + +// --- 2. ATM Application Components --- + +// Global State +const [getView, setView] = createSignal("pin"); // 'pin', 'menu', 'withdraw', 'custom_withdraw', 'balance' +const [getPin, setPin] = createSignal(""); +const [getCustomAmount, setCustomAmount] = createSignal(""); // For custom withdrawal +const [getBalance, setBalance] = createSignal(1250000); // Shared mockup balance +const [getMessage, setMessage] = createSignal(""); // For feedback + +// Header +function Header() { + return h( + "div", + { className: "header", style: { marginBottom: "2rem" } }, + h("h1", null, "ATM TERMINAL"), + h("p", null, "Global Financial Network"), + ); +} + +// PIN Entry View +function PinView() { + const currentPin = getPin(); + + const handleNumClick = (num) => { + if (currentPin.length < 4) { + setPin((prev) => prev + num); + } + }; + + const handleClear = () => setPin(""); + const handleEnter = () => { + if (currentPin.length === 4) { + // Mock auth success + setView("menu"); + } else { + setMessage("Invalid PIN Length"); + setTimeout(() => setMessage(""), 2000); + } + }; + + return h( + "div", + { className: "card", style: { padding: "3rem 2rem" } }, + h("h2", null, "Enter Security PIN"), + h( + "div", + { className: "pin-display" }, + currentPin.replace(/./g, "•") || "----", + ), + h("p", { style: { color: "red", height: "1.5rem" } }, getMessage()), + h( + "div", + { className: "numpad" }, + ["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((num) => + h("button", { onClick: () => handleNumClick(num) }, num), + ), + h( + "button", + { + style: { background: "#ef4444", color: "white" }, + onClick: handleClear, + }, + "C", + ), + h("button", { onClick: () => handleNumClick("0") }, "0"), + h( + "button", + { + style: { background: "#10b981", color: "white" }, + onClick: handleEnter, + }, + "↵", + ), + ), + ); +} + +// Main Menu View +function MenuView() { + return h( + "div", + { className: "kiosk-content" }, + h( + "h2", + { style: { textAlign: "center", marginBottom: "1rem" } }, + "Select Transaction", + ), + h( + "div", + { className: "kiosk-menu-stack" }, + h( + "button", + { className: "kiosk-btn", onClick: () => setView("withdraw") }, + "Withdraw Cash", + ), + h( + "button", + { className: "kiosk-btn", onClick: () => setView("balance") }, + "Check Balance", + ), + h( + "button", + { + className: "kiosk-btn", + style: { + background: "var(--bg-surface)", + color: "var(--text-main)", + border: "1px solid var(--border)", + }, + onClick: () => { + setPin(""); + setView("pin"); + }, + }, + "Cancel Transaction", + ), + ), + ); +} + +// Withdraw View +function WithdrawView() { + const handleWithdraw = (amount) => { + if (getBalance() >= amount) { + setBalance((prev) => prev - amount); + setMessage(`Please take your cash: $${amount}`); + setTimeout(() => { + setMessage(""); + setView("menu"); + }, 3000); + } else { + setMessage("Insufficient Funds"); + setTimeout(() => setMessage(""), 2000); + } + }; + + if (getMessage()) { + return h( + "div", + { + className: "card", + style: { padding: "4rem", textAlign: "center" }, + }, + h("h2", { style: { color: "var(--primary)" } }, getMessage()), + ); + } + + return h( + "div", + { className: "kiosk-content" }, + h( + "h2", + { style: { textAlign: "center", marginBottom: "1rem" } }, + "Select Amount", + ), + h( + "div", + { className: "kiosk-grid" }, + h( + "button", + { className: "kiosk-btn", onClick: () => handleWithdraw(20) }, + "$20", + ), + h( + "button", + { className: "kiosk-btn", onClick: () => handleWithdraw(50) }, + "$50", + ), + h( + "button", + { className: "kiosk-btn", onClick: () => handleWithdraw(100) }, + "$100", + ), + h( + "button", + { + className: "kiosk-btn", + onClick: () => { + setCustomAmount(""); + setView("custom_withdraw"); + }, + }, + "Other Amount", + ), + h( + "button", + { + className: "kiosk-btn", + style: { + gridColumn: "span 2", + background: "var(--text-muted)", + }, + onClick: () => setView("menu"), + }, + "Cancel", + ), + ), + ); +} + +// Custom Withdraw View +function CustomWithdrawView() { + const currentAmount = getCustomAmount(); + + const handleNumClick = (num) => { + if (currentAmount.length < 5) { + // Limit to 5 digits for safety + setCustomAmount((prev) => prev + num); + } + }; + + const handleClear = () => setCustomAmount(""); + + const handleEnter = () => { + const amount = parseInt(currentAmount, 10); + if (amount > 0) { + if (getBalance() >= amount) { + setBalance((prev) => prev - amount); + setMessage(`Please take your cash: $${amount}`); + setTimeout(() => { + setMessage(""); + setView("menu"); + }, 3000); + } else { + setMessage("Insufficient Funds"); + setTimeout(() => setMessage(""), 2000); + } + } else { + setMessage("Invalid Amount"); + setTimeout(() => setMessage(""), 2000); + } + }; + + if (getMessage()) { + return h( + "div", + { + className: "card", + style: { padding: "4rem", textAlign: "center" }, + }, + h("h2", { style: { color: "var(--primary)" } }, getMessage()), + ); + } + + return h( + "div", + { className: "card", style: { padding: "3rem 2rem" } }, + h("h2", null, "Enter Amount"), + h( + "div", + { className: "pin-display" }, + currentAmount ? `$${currentAmount}` : "$0", + ), + h( + "div", + { className: "numpad" }, + ["1", "2", "3", "4", "5", "6", "7", "8", "9"].map((num) => + h("button", { onClick: () => handleNumClick(num) }, num), + ), + h( + "button", + { + style: { background: "#ef4444", color: "white" }, + onClick: handleClear, + }, + "C", + ), + h("button", { onClick: () => handleNumClick("0") }, "0"), + h( + "button", + { + style: { background: "#10b981", color: "white" }, + onClick: handleEnter, + }, + "↵", + ), + ), + h( + "button", + { + style: { + width: "100%", + marginTop: "2rem", + padding: "1rem", + background: "var(--text-muted)", + }, + onClick: () => setView("withdraw"), + }, + "Cancel", + ), + ); +} + +// Balance View +function BalanceView() { + return h( + "div", + { className: "card", style: { textAlign: "center", padding: "3rem" } }, + h("h2", { style: { color: "var(--text-muted)" } }, "Available Balance"), + h( + "div", + { + style: { + fontSize: "4rem", + fontWeight: "800", + margin: "2rem 0", + color: "var(--primary-hover)", + }, + }, + "$" + getBalance().toLocaleString(), + ), + h( + "button", + { + className: "kiosk-btn", + style: { width: "100%", maxWidth: "300px", margin: "0 auto" }, + onClick: () => setView("menu"), + }, + "Return to Menu", + ), + ); +} + +// Main App +function App() { + const view = getView(); + + let mainContent; + if (view === "pin") { + mainContent = PinView(); + } else if (view === "menu") { + mainContent = MenuView(); + } else if (view === "withdraw") { + mainContent = WithdrawView(); + } else if (view === "custom_withdraw") { + mainContent = CustomWithdrawView(); + } else if (view === "balance") { + mainContent = BalanceView(); + } + + return h( + "main", + null, + h("div", { className: "container" }, Header(), mainContent), + ); +} + +// Mount +const root = document.getElementById("app"); +render(App, root); diff --git a/arma/ui/bank.html b/arma/ui/bank.html new file mode 100644 index 0000000..64c2005 --- /dev/null +++ b/arma/ui/bank.html @@ -0,0 +1,14 @@ + + + + + + FDIC - Global Financial Network + + + + +
+ + + diff --git a/arma/ui/bank.js b/arma/ui/bank.js new file mode 100644 index 0000000..75038cf --- /dev/null +++ b/arma/ui/bank.js @@ -0,0 +1,441 @@ +/** + * Player Bank App - Vanilla JS "React-like" Implementation + */ + +// --- 1. The "Library" Logic (Reused) --- + +function h(tag, props = {}, ...children) { + const el = document.createElement(tag); + if (props) { + Object.entries(props).forEach(([key, value]) => { + if (key.startsWith("on") && typeof value === "function") { + el.addEventListener(key.substring(2).toLowerCase(), value); + } else if (key === "className") { + el.className = value; + } else if (key === "style" && typeof value === "object") { + Object.assign(el.style, value); + } else { + el.setAttribute(key, value); + } + }); + } + children.forEach((child) => { + if (typeof child === "string" || typeof child === "number") { + el.appendChild(document.createTextNode(child)); + } else if (child instanceof Node) { + el.appendChild(child); + } else if (Array.isArray(child)) { + child.forEach((c) => el.appendChild(c)); + } + }); + return el; +} + +let _rootContainer = null; +let _rootComponent = null; + +function render(component, container) { + _rootContainer = container; + _rootComponent = component; + _render(); +} + +function _render() { + _rootContainer.innerHTML = ""; + _rootContainer.appendChild(_rootComponent()); +} + +const createSignal = (initialValue) => { + let _val = initialValue; + const getValue = () => _val; + const setValue = (newValue) => { + _val = typeof newValue === "function" ? newValue(_val) : newValue; + _render(); + }; + return [getValue, setValue]; +}; + +// --- 2. Bank Application Components --- + +// Global State +const [getView, setView] = createSignal("login"); // 'login', 'dashboard' +const [getBalance, setBalance] = createSignal(1250000); +const [getPending, setPending] = createSignal(45250); // Mock pending earnings +const [getTransactions, setTransactions] = createSignal([ + { + id: 1, + type: "credit", + desc: "Contract Payment: OP-442", + amount: 150000, + date: "2026-02-05", + }, + { + id: 2, + type: "debit", + desc: "Equipment Purchase: Ammunition", + amount: -4500, + date: "2026-02-04", + }, + { + id: 3, + type: "debit", + desc: "Vehicle Maintenance", + amount: -1200, + date: "2026-02-03", + }, +]); + +// Header +function Header() { + return h( + "div", + { className: "header" }, + h( + "h1", + { + style: { cursor: "pointer" }, + onClick: () => setView("login"), + }, + "Global Financial Network", + ), + h("p", null, "Secure Banking"), + ); +} + +// Login View +function BankLogin() { + const handleSubmit = (e) => { + e.preventDefault(); + setView("dashboard"); + }; + + return h( + "div", + { className: "card", style: { maxWidth: "400px", margin: "0 auto" } }, + h("h2", null, "Secure Access"), + h( + "form", + { onSubmit: handleSubmit }, + h( + "div", + null, + h("label", null, "Account ID"), + h("input", { type: "text", placeholder: "xxxx-xxxx-xxxx" }), + ), + h( + "div", + null, + h("label", null, "Security PIN"), + h("input", { type: "password", placeholder: "••••" }), + ), + h( + "div", + { className: "form-actions" }, + h( + "button", + { type: "submit", style: { width: "100%" } }, + "Authenticate", + ), + h( + "p", + { + style: { + fontSize: "0.8rem", + color: "var(--text-muted)", + marginTop: "1rem", + }, + }, + "Authorized Personnel Only", + ), + ), + ), + ); +} + +// Transaction History Helper +function TransactionHistory() { + const transactions = getTransactions(); + + return h( + "div", + { className: "card" }, + h( + "h3", + { + style: { + textAlign: "left", + borderBottom: "1px solid var(--border)", + paddingBottom: "1rem", + marginBottom: "1rem", + }, + }, + "Recent Transactions", + ), + h( + "ul", + { style: { listStyle: "none", padding: 0 } }, + ...transactions.map((tx) => + h( + "li", + { + style: { + display: "flex", + justifyContent: "space-between", + padding: "0.75rem 0", + borderBottom: "1px solid var(--bg-surface-hover)", + }, + }, + h( + "div", + { style: { textAlign: "left" } }, + h("div", { style: { fontWeight: "500" } }, tx.desc), + h( + "div", + { + style: { + fontSize: "0.85rem", + color: "var(--text-muted)", + }, + }, + tx.date, + ), + ), + h( + "div", + { + style: { + fontWeight: "700", + color: + tx.type === "credit" + ? "#10b981" + : "#ef4444", + }, + }, + (tx.type === "credit" ? "+" : "") + + "$" + + Math.abs(tx.amount).toLocaleString(), + ), + ), + ), + ), + ); +} + +// Transfer Form +function TransferForm() { + const handleSubmit = (e) => { + e.preventDefault(); + const formData = new FormData(e.target); + const amount = parseFloat(formData.get("amount")); + + if (amount > 0 && amount <= getBalance()) { + setBalance((prev) => prev - amount); + const newTx = { + id: Date.now(), + type: "debit", + desc: "Transfer to " + formData.get("recipient"), + amount: -amount, + date: new Date().toISOString().split("T")[0], + }; + setTransactions((prev) => [newTx, ...prev]); + } + }; + + return h( + "div", + { className: "card" }, + h("h2", null, "Wire Transfer"), + h( + "form", + { onSubmit: handleSubmit }, + h( + "div", + null, + h("label", null, "Recipient Name / GUID"), + h("input", { + name: "recipient", + type: "text", + placeholder: "Enter Name or GUID", + }), + ), + h( + "div", + null, + h("label", null, "Amount"), + h("input", { + name: "amount", + type: "number", + placeholder: "0.00", + }), + ), + h("button", { type: "submit" }, "Send Funds"), + ), + ); +} + +// Dashboard View +function BankDashboard() { + return h( + "div", + { className: "content" }, + // Top Row: Balance + h( + "div", + { className: "card", style: { gridColumn: "span 2" } }, + h( + "h2", + { + style: { + fontSize: "1.2rem", + color: "var(--text-muted)", + textTransform: "uppercase", + letterSpacing: "0.05em", + }, + }, + "Total Balance", + ), + h( + "div", + { + style: { + fontSize: "2.8rem", + fontWeight: "800", + color: "var(--primary-hover)", + margin: "1rem 0", + }, + }, + "$" + getBalance().toLocaleString(), + ), + h( + "div", + { + style: { + textAlign: "center", + marginBottom: "1.5rem", + color: "var(--text-muted)", + fontSize: "1.2rem", + }, + }, + "Pending: ", + h( + "span", + { style: { color: "#fbbf24", fontWeight: "bold" } }, + "$" + getPending().toLocaleString(), + ), + ), + h( + "div", + { + style: { + display: "flex", + gap: "1rem", + justifyContent: "center", + }, + }, + h( + "button", + { + onClick: () => { + const pending = getPending(); + if (pending > 0) { + setBalance((prev) => prev + pending); + setPending(0); + const newTx = { + id: Date.now(), + type: "credit", + desc: "Field Deposit", + amount: pending, + date: new Date() + .toISOString() + .split("T")[0], + }; + setTransactions((prev) => [newTx, ...prev]); + } + }, + style: { + opacity: getPending() > 0 ? "1" : "0.5", + cursor: getPending() > 0 ? "pointer" : "default", + }, + }, + "Deposit Pending", + ), + h( + "button", + { + style: { + background: "var(--bg-surface-hover)", + color: "var(--text-main)", + border: "1px solid var(--border)", + }, + }, + "Statement", + ), + ), + ), + // Middle Row: Transfer Form + TransferForm(), + // Bottom Row: History (Full Width in simplified grid, or separate) + TransactionHistory(), + ); +} + +// Footer +function Footer() { + return h( + "div", + { className: "footer" }, + h( + "div", + { className: "wrapper" }, + h( + "div", + null, + h("h3", null, "Secure Banking"), + h( + "ul", + { style: { listStyleType: "none", padding: 0 } }, + h("li", null, "FDIC Insured"), + h("li", null, "Fraud Protection"), + h("li", null, "24/7 Support"), + h("li", null, "API Access"), + ), + ), + h( + "div", + null, + h("h3", null, "Notices"), + h( + "ul", + { style: { listStyleType: "none", padding: 0 } }, + h("li", null, "Terms of Service"), + h("li", null, "Privacy Policy"), + h("li", null, "Interest Rates"), + h("li", null, "Report Fraud"), + ), + ), + ), + ); +} + +// Main App +function App() { + const view = getView(); + + let mainContent; + if (view === "login") { + mainContent = BankLogin(); + } else if (view === "dashboard") { + mainContent = BankDashboard(); + } + + return h( + "main", + null, + h("div", { className: "container" }, Header(), mainContent), + Footer(), + ); +} + +// Mount +const root = document.getElementById("app"); +render(App, root); diff --git a/arma/ui/style.css b/arma/ui/style.css new file mode 100644 index 0000000..f79f5ae --- /dev/null +++ b/arma/ui/style.css @@ -0,0 +1,306 @@ +:root { + --bg-app: #fdfcf8; + /* Warm white */ + --bg-surface: #ffffff; + --bg-surface-hover: #f1f5f9; + --primary: #475569; + /* Slate gray */ + --primary-hover: #1e293b; + --text-main: #1f2937; + --text-muted: #64748b; + --text-inverse: #f8fafc; + --border: #e2e8f0; + --radius: 8px; + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --footer-bg: #1e293b; + /* Dark slate for footer */ +} + +body { + font-family: + "Inter", + system-ui, + -apple-system, + sans-serif; + margin: 0; + padding: 0; + background: var(--bg-app); + color: var(--text-main); + line-height: 1.6; +} + +#app { + min-height: 100vh; +} + +main { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.container { + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 2rem; + flex: 1; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +/* Header */ +.header { + text-align: center; + margin-bottom: 3rem; + padding-bottom: 2rem; + border-bottom: 1px solid var(--border); + + h1 { + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 0.5rem; + letter-spacing: -0.025em; + color: var(--primary-hover); + } + + p { + color: var(--text-muted); + font-size: 1.1rem; + } +} + +.content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + margin-bottom: 2rem; +} + +/* Cards */ +.card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + box-shadow: var(--shadow); + text-align: center; + + h2 { + margin-top: 0; + font-size: 1.8rem; + color: var(--primary-hover); + } +} + +/* Buttons */ +button { + background: var(--primary); + color: white; + border: none; + padding: 0.75rem 1.5rem; + border-radius: var(--radius); + cursor: pointer; + font-size: 1rem; + font-weight: 500; + transition: all 0.2s ease; + + &:hover { + background: var(--primary-hover); + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); + } + + & + & { + margin-left: 1rem; + } +} + +/* Split Layout */ +.split-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + align-items: center; + width: 100%; +} + +.info-panel { + text-align: left; + padding: 1rem; +} + +/* Forms */ +form { + display: flex; + flex-direction: column; + gap: 1rem; + text-align: left; + + label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-muted); + font-weight: 500; + font-size: 0.9rem; + } + + input, + select { + width: 100%; + padding: 0.75rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--bg-app); + color: var(--text-main); + font-family: inherit; + font-size: 1rem; + box-sizing: border-box; + transition: border-color 0.2s; + + &:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); + } + } + + .form-actions { + margin-top: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + } + + .cancel-link { + font-size: 0.9rem; + color: var(--text-muted); + cursor: pointer; + text-decoration: underline; + + &:hover { + color: var(--primary); + } + } +} + +/* Footer */ +.footer { + margin-top: auto; + background: var(--footer-bg); + color: var(--text-inverse); + display: block; + + .wrapper { + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 3rem 2rem; + box-sizing: border-box; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4rem; + } + + h3 { + color: var(--text-inverse); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.1em; + font-weight: 700; + margin-bottom: 1.5rem; + border-bottom: 1px solid #475569 !important; + padding-bottom: 0.5rem; + margin-right: 1rem; + } + + ul { + li { + color: #cbd5e1; + font-size: 0.95rem; + margin-bottom: 0.75rem !important; + cursor: pointer; + transition: color 0.2s; + + &:hover { + color: white; + } + } + } +} + +/* ATM Kiosk Styles */ +.kiosk-content { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +.kiosk-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; + margin-top: 2rem; + width: 100%; + max-width: 600px; + /* Constrain width for better look */ +} + +.kiosk-menu-stack { + display: flex; + flex-direction: column; + gap: 1.5rem; + margin-top: 2rem; + width: 100%; + max-width: 600px; + /* Narrower for vertical stack */ +} + +.kiosk-btn { + padding: 2rem; + font-size: 1.25rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + height: 100%; + min-height: 120px; + margin: 0; +} + +.numpad { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + max-width: 300px; + margin: 0 auto; + + button { + padding: 1.5rem; + font-size: 1.5rem; + background: var(--bg-surface); + color: var(--text-main); + border: 1px solid var(--border); + box-shadow: var(--shadow); + margin: 0; + + &:hover { + background: var(--primary); + color: white; + border-color: var(--primary); + } + } +} + +.pin-display { + font-size: 2.5rem; + letter-spacing: 0.5rem; + text-align: center; + margin-bottom: 2rem; + font-family: monospace; + color: var(--primary); +} diff --git a/bin/icom/README.md b/bin/icom/README.md index c603dc0..7adb73b 100644 --- a/bin/icom/README.md +++ b/bin/icom/README.md @@ -82,16 +82,17 @@ First message from each Arma server to identify itself: ```json { - "type": "register", - "server_id": "server_1" + "type": "register", + "server_id": "server_1" } ``` Response: + ```json { - "type": "registered", - "session_id": "uuid-here" + "type": "registered", + "session_id": "uuid-here" } ``` @@ -101,27 +102,28 @@ Send an event with arbitrary JSON data to a specific server: ```json { - "type": "event", - "target_server": "server_2", - "event_name": "supply_drop", - "data": { - "coords": [1234.5, 5678.9, 0.0], - "supplies": ["ammo_box", "medical_supplies"] - } + "type": "event", + "target_server": "server_2", + "event_name": "supply_drop", + "data": { + "coords": [1234.5, 5678.9, 0.0], + "supplies": ["ammo_box", "medical_supplies"] + } } ``` Another example: + ```json { - "type": "event", - "target_server": "server_2", - "event_name": "spawn_mission", - "data": { - "mission_type": "convoy_ambush", - "difficulty": "hard", - "location": [1234, 5678, 0] - } + "type": "event", + "target_server": "server_2", + "event_name": "spawn_mission", + "data": { + "mission_type": "convoy_ambush", + "difficulty": "hard", + "location": [1234, 5678, 0] + } } ``` @@ -131,12 +133,12 @@ Send event to all connected servers (except sender): ```json { - "type": "broadcast", - "event_name": "global_alert", - "data": { - "message": "Nuclear strike incoming!", - "severity": "critical" - } + "type": "broadcast", + "event_name": "global_alert", + "data": { + "message": "Nuclear strike incoming!", + "severity": "critical" + } } ``` @@ -146,20 +148,21 @@ Response to successful message delivery: ```json { - "type": "ack", - "message_id": null, - "success": true, - "error": null + "type": "ack", + "message_id": null, + "success": true, + "error": null } ``` Error response: + ```json { - "type": "ack", - "message_id": null, - "success": false, - "error": "Target server 'server_3' not found" + "type": "ack", + "message_id": null, + "success": false, + "error": "Target server 'server_3' not found" } ``` @@ -215,6 +218,7 @@ The Forge server extension includes full ICOM integration: 4. **Extension Commands**: Provides SQF commands to send/receive events **Important Notes**: + - The extension uses `try_read()` to avoid deadlocks when accessing context from async tasks - Broadcast events are **not** sent back to the originating server - Connection can be initiated manually if automatic startup connection fails @@ -355,20 +359,20 @@ You should see events flow from server_2 → ICOM → server_1. 1. Start ICOM server 2. Start Arma 3 server with Forge extension 3. In Arma, connect manually (if needed): - ```sqf - "forge_server" callExtension ["icom:connect", ["127.0.0.1:9090", "server_1"]] - ``` + ```sqf + "forge_server" callExtension ["icom:connect", ["127.0.0.1:9090", "server_1"]] + ``` 4. Set up CBA event handler in mission init: - ```sqf - ["forge_icom_event", { - params ["_eventName", "_data"]; - systemChat format ["ICOM Event: %1", _eventName]; - }] call CBA_fnc_addEventHandler; - ``` + ```sqf + ["forge_icom_event", { + params ["_eventName", "_data"]; + systemChat format ["ICOM Event: %1", _eventName]; + }] call CBA_fnc_addEventHandler; + ``` 5. Run example sender client to test event reception: - ```powershell - cargo run --example server_2_client - ``` + ```powershell + cargo run --example server_2_client + ``` 6. Check logs at `@forge_server/logs/icom.log` to verify events are received ## Next Steps @@ -383,6 +387,7 @@ You should see events flow from server_2 → ICOM → server_1. ## Monitoring The ICOM server logs all important events to stdout: + - 🔥 Server startup - 📡 New connections - ✅ Server registrations diff --git a/build-arma.ps1 b/build-arma.ps1 index fe6a29f..d7b67c3 100644 --- a/build-arma.ps1 +++ b/build-arma.ps1 @@ -28,6 +28,22 @@ param( $ErrorActionPreference = "Stop" $scriptDir = $PSScriptRoot +function Build-WebUIAssets { + Write-Host "`n=== Building Web UI Bundles ===" -ForegroundColor Cyan + + Push-Location $scriptDir + try { + & npm run build:webui + if ($LASTEXITCODE -ne 0) { + throw "Web UI bundle build failed with exit code $LASTEXITCODE" + } + Write-Host "✓ Web UI bundles built successfully" -ForegroundColor Green + } + finally { + Pop-Location + } +} + function Build-HemttProject { param( [string]$ProjectPath, @@ -54,6 +70,7 @@ $serverPath = Join-Path $scriptDir "arma\server" try { if ($Target -eq 'client' -or $Target -eq 'both') { + Build-WebUIAssets Build-HemttProject -ProjectPath $clientPath -ProjectName "Client" } diff --git a/dev-arma.ps1 b/dev-arma.ps1 deleted file mode 100644 index 4939b42..0000000 --- a/dev-arma.ps1 +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env pwsh -<# -.SYNOPSIS - Build both arma/client and arma/server using hemtt - -.DESCRIPTION - This script runs hemtt dev for both the client and server Arma mods. - It changes to each directory and runs the build command. - -.PARAMETER Target - Specify which target to build: 'client', 'server', or 'both' (default) - -.EXAMPLE - .\build-arma.ps1 - Builds both client and server - -.EXAMPLE - .\build-arma.ps1 -Target client - Builds only the client -#> - -param( - [Parameter(Mandatory=$false)] - [ValidateSet('client', 'server', 'both')] - [string]$Target = 'both' -) - -$ErrorActionPreference = "Stop" -$scriptDir = $PSScriptRoot - -function Build-HemttProject { - param( - [string]$ProjectPath, - [string]$ProjectName - ) - - Write-Host "`n=== Building $ProjectName ===" -ForegroundColor Cyan - - Push-Location $ProjectPath - try { - & hemtt utils fnl && hemtt dev - if ($LASTEXITCODE -ne 0) { - throw "hemtt dev failed for $ProjectName with exit code $LASTEXITCODE" - } - Write-Host "✓ $ProjectName build successful" -ForegroundColor Green - } - finally { - Pop-Location - } -} - -$clientPath = Join-Path $scriptDir "arma\client" -$serverPath = Join-Path $scriptDir "arma\server" - -try { - if ($Target -eq 'client' -or $Target -eq 'both') { - Build-HemttProject -ProjectPath $clientPath -ProjectName "Client" - } - - if ($Target -eq 'server' -or $Target -eq 'both') { - Build-HemttProject -ProjectPath $serverPath -ProjectName "Server" - } - - Write-Host "`n=== Build Complete ===" -ForegroundColor Green -} -catch { - Write-Host "`n✗ Build failed: $_" -ForegroundColor Red - exit 1 -} diff --git a/docs/GARAGE_USAGE_GUIDE.md b/docs/GARAGE_USAGE_GUIDE.md index 44218f4..fe8d8d3 100644 --- a/docs/GARAGE_USAGE_GUIDE.md +++ b/docs/GARAGE_USAGE_GUIDE.md @@ -1,9 +1,11 @@ # Garage System Integration Guide ## Overview + The garage system provides complete vehicle storage, retrieval, and management for Arma 3 players. Each player can store multiple vehicles with full damage and hit point tracking. ## Data Storage + - Each player's garage is stored as a single JSON object (map) at Redis key: `garage:{playerUID}` - The map is keyed by the vehicle's unique plate (UUID) - Each vehicle tracks: plate (UUID), classname, overall damage, fuel, and detailed hit points @@ -15,6 +17,7 @@ The garage system provides complete vehicle storage, retrieval, and management f All commands are accessed via the `garage` group: ### Create Garage + Creates a new empty garage for a player. Should be called when initializing a new player. ```sqf @@ -24,6 +27,7 @@ private _emptyGarage = fromJSON (_result select 0); ``` ### Get Garage + Retrieves all vehicles in a player's garage. ```sqf @@ -33,6 +37,7 @@ private _garageMap = fromJSON (_result select 0); ``` ### Add Vehicle + Adds a new vehicle to the garage. The system automatically generates a unique plate (UUID) for the vehicle. ```sqf @@ -53,6 +58,7 @@ private _updatedGarage = fromJSON (_result select 0); ``` ### Update Garage (Sync) + Updates the entire garage state. Useful for syncing changes made locally. ```sqf @@ -66,6 +72,7 @@ private _updatedGarage = fromJSON (_result select 0); ``` ### Patch Vehicle + Updates specific fields of a vehicle without sending the entire garage. Useful for frequent updates like fuel or damage. ```sqf @@ -86,6 +93,7 @@ private _updatedGarage = fromJSON (_result select 0); ``` ### Remove Vehicle + Removes a specific vehicle from the garage by plate number. Note: If using the GarageStore, removing a vehicle locally and saving/syncing will also remove it from the server. @@ -105,6 +113,7 @@ private _updatedGarage = fromJSON (_result select 0); ``` ### Delete Garage + Permanently deletes all vehicles from a player's garage. ```sqf @@ -113,6 +122,7 @@ private _result = "forge_server" callExtension ["garage:delete", [getPlayerUID p ``` ### Check Existence + Checks if a player has any vehicles in their garage. ```sqf @@ -254,9 +264,9 @@ if (_data find "Error:" == 0) then { ## Best Practices 1. **Track Vehicle Plates**: When spawning vehicles from the garage, store the plate (UUID) as a variable so you can update them later: - ```sqf - _vehicle setVariable ["garagePlate", _plate]; - ``` + ```sqf + _vehicle setVariable ["garagePlate", _plate]; + ``` 2. **Auto-Creation**: The system automatically creates an empty garage for new players on first access 3. **Validate Before Storage**: Check that vehicles are in good condition before allowing storage 4. **Limit Garage Size**: Implement a maximum number of vehicles per player diff --git a/docs/LOCKER_USAGE_GUIDE.md b/docs/LOCKER_USAGE_GUIDE.md index d0583de..87eff08 100644 --- a/docs/LOCKER_USAGE_GUIDE.md +++ b/docs/LOCKER_USAGE_GUIDE.md @@ -1,9 +1,11 @@ # Locker System Integration Guide ## Overview + The locker system provides persistent item storage for Arma 3 players. Each player can store multiple items (weapons, magazines, equipment) with quantity tracking. ## Data Storage + - Each player's locker is stored as a single JSON object (map) at Redis key: `locker:{playerUID}` - The map is keyed by the item's **classname** (String) - Each item tracks: `category`, `classname`, and `amount` @@ -15,6 +17,7 @@ The locker system provides persistent item storage for Arma 3 players. Each play All commands are accessed via the `locker` group: ### Create Locker + Creates a new empty locker for a player. Should be called when initializing a new player if one doesn't exist. ```sqf @@ -23,6 +26,7 @@ private _result = "forge_server" callExtension ["locker:create", [getPlayerUID p ``` ### Get Locker + Retrieves all items in a player's locker. ```sqf @@ -32,6 +36,7 @@ private _lockerMap = fromJSON (_result select 0); ``` ### Add Item + Adds a new item to the locker or updates the amount if it already exists. **Checks capacity limit (25 items).** @@ -52,6 +57,7 @@ private _updatedLocker = fromJSON (_result select 0); ``` ### Update Locker (Sync) + Updates the entire locker state. Useful for syncing changes made locally (e.g., bulk moves). **Replaces the entire locker content.** @@ -66,6 +72,7 @@ private _updatedLocker = fromJSON (_result select 0); ``` ### Patch Item + Updates specific fields (currently `amount`) of an existing item without sending the entire locker. **Efficient for quantity changes.** @@ -85,6 +92,7 @@ private _updatedLocker = fromJSON (_result select 0); ``` ### Remove Item + Removes a specific item from the locker by classname. ```sqf @@ -102,6 +110,7 @@ private _updatedLocker = fromJSON (_result select 0); ``` ### Delete Locker + Permanently deletes all items from a player's locker. ```sqf @@ -110,6 +119,7 @@ private _result = "forge_server" callExtension ["locker:delete", [getPlayerUID p ``` ### Check Existence + Checks if a player has a locker created. ```sqf diff --git a/examples/ext_data.sqf b/examples/ext_data.sqf index 1318277..f384341 100644 --- a/examples/ext_data.sqf +++ b/examples/ext_data.sqf @@ -19,12 +19,11 @@ // Locker Registry [["_SP_PLAYER_",[["30Rnd_65x39_caseless_mag",[["amount",4],["classname","30Rnd_65x39_caseless_mag"],["category","magazine"]]],["arifle_MX_F",[["amount",1],["classname","arifle_MX_F"],["category","weapon"]]],["NVGoggles",[["amount",1],["classname","NVGoggles"],["category","hmd"]]]]]]; -// Organization Index Registry +// Org Registry +[["default",[["name","Forge Dynamics"],["id","default"],["funds",200000],["reputation",0],["owner","server"]]],["0160566824",[["name","Black Rifle Company"],["id","0160566824"],["funds",0],["reputation",0],["owner","_SP_PLAYER_"],["members",[["_SP_PLAYER_",[["name","Jacob Schmidt"],["uid","_SP_PLAYER_"]]]]]]]]; +// Org Index Registry [["_SP_PLAYER_",[["orgID","0160566824"]]]]; -// Organization Registry -[["0160566824",[["assets",[]],["name","Black Rifle Company"],["id","0160566824"],["funds",0],["reputation",0],["owner","_SP_PLAYER_"],["members",[["_SP_PLAYER_",[["name","Jacob Schmidt"],["uid","_SP_PLAYER_"]]]]]]]]; - // Player Session Registry [["_SP_PLAYER_",[["sessionToken","855837"]]]]; diff --git a/forge.code-workspace b/forge.code-workspace index deb0ab6..fc23c3d 100644 --- a/forge.code-workspace +++ b/forge.code-workspace @@ -1,8 +1,8 @@ { "folders": [ { - "path": "." - } + "path": ".", + }, ], "settings": { "editor.insertSpaces": true, @@ -16,14 +16,14 @@ "*.hpp": "arma-config", "*.inc": "arma-config", "*.cfg": "arma-config", - "*.rvmat": "arma-config" + "*.rvmat": "arma-config", }, "psi-header.changes-tracking": { "isActive": true, "include": ["sqf"], "modDate": "Last Update:", "modDateFormat": "YYYY-MM-DD", - "autoHeader": "manualSave" + "autoHeader": "manualSave", }, "psi-header.lang-config": [ { @@ -36,8 +36,8 @@ "prefix": " * ", "end": " */", "blankLinesAfter": 1, - "forceToTop": true - } + "forceToTop": true, + }, ], "psi-header.templates": [ { @@ -59,9 +59,9 @@ " Something [BOOL]", "", "Example(s):", - " [parameter] call forge_x_component_fnc_myFunction" - ] - } + " [parameter] call forge_x_component_fnc_myFunction", + ], + }, ], - } + }, } diff --git a/lib/README.md b/lib/README.md index c82ce4a..05c2b62 100644 --- a/lib/README.md +++ b/lib/README.md @@ -25,12 +25,14 @@ graph TD **Purpose**: Defines strict data structures and validation rules for domain entities. **Responsibilities**: + - Define entity structures (`Actor`, `Org`) - Implement validation logic - Handle serialization/deserialization (JSON, Arma) - Enforce business rules at the data level **Key Features**: + - Strong typing with Rust structs - Built-in validation on creation and updates - Automatic email generation for actors @@ -43,12 +45,14 @@ graph TD **Purpose**: Manages data persistence and retrieval using Redis. **Responsibilities**: + - Abstract database operations - Implement CRUD operations - Handle data serialization to Redis formats - Manage Redis keys and data structures **Key Features**: + - Generic over Redis client implementations - Hash-based storage for structured data - Set-based storage for collections (e.g., org members) @@ -61,12 +65,14 @@ graph TD **Purpose**: Implements business logic, validation, and orchestration of operations. **Responsibilities**: + - Coordinate between repositories - Enforce business rules - Handle complex workflows - Provide high-level APIs for the extension layer **Key Features**: + - Generic over repository implementations - Stateless service design - Get-or-create patterns for entities @@ -79,12 +85,14 @@ graph TD **Purpose**: Provides common utilities, traits, and helper functions used across all layers. **Responsibilities**: + - Define shared traits (`RedisClient`) - Provide utility functions - Common type definitions - Cross-cutting concerns **Key Features**: + - `RedisClient` trait for repository abstraction - JSON/Redis value parsing utilities - Arma value conversion helpers @@ -97,57 +105,61 @@ graph TD Here's how the layers interact when creating a new actor: 1. **Extension Layer** receives SQF command: - ```rust - // arma/server/extension/src/actor.rs - pub fn create_actor(key: String, data: String) -> String { - // Parse JSON and call service - ACTOR_SERVICE.create_actor(uid, json_data) - } - ``` + + ```rust + // arma/server/extension/src/actor.rs + pub fn create_actor(key: String, data: String) -> String { + // Parse JSON and call service + ACTOR_SERVICE.create_actor(uid, json_data) + } + ``` 2. **Service Layer** validates and orchestrates: - ```rust - // lib/services/src/actor.rs - impl ActorService { - pub fn create_actor(&self, uid: String, data: String) -> Result { - // Create actor model (validates data) - let actor = Actor::new(uid, data)?; - // Persist via repository - self.repository.create(&actor)?; + ```rust + // lib/services/src/actor.rs + impl ActorService { + pub fn create_actor(&self, uid: String, data: String) -> Result { + // Create actor model (validates data) + let actor = Actor::new(uid, data)?; - Ok(actor) - } - } - ``` + // Persist via repository + self.repository.create(&actor)?; + + Ok(actor) + } + } + ``` 3. **Repository Layer** persists to Redis: - ```rust - // lib/repositories/src/actor.rs - impl ActorRepository for RedisActorRepository { - fn create(&self, actor: &Actor) -> Result<(), String> { - // Convert actor to Redis hash - let fields = actor.to_redis_fields(); - // Store in Redis - self.client.hash_mset(format!("actor:{}", actor.uid), fields) - } - } - ``` + ```rust + // lib/repositories/src/actor.rs + impl ActorRepository for RedisActorRepository { + fn create(&self, actor: &Actor) -> Result<(), String> { + // Convert actor to Redis hash + let fields = actor.to_redis_fields(); + + // Store in Redis + self.client.hash_mset(format!("actor:{}", actor.uid), fields) + } + } + ``` 4. **Model Layer** ensures data integrity: - ```rust - // lib/models/src/actor.rs - impl Actor { - pub fn new(uid: String, data: String) -> Result { - // Validate all fields - Self::validate(&uid, &data)?; - // Create actor with validated data - Ok(Actor { uid, /* ... */ }) - } - } - ``` + ```rust + // lib/models/src/actor.rs + impl Actor { + pub fn new(uid: String, data: String) -> Result { + // Validate all fields + Self::validate(&uid, &data)?; + + // Create actor with validated data + Ok(Actor { uid, /* ... */ }) + } + } + ``` ## Contributing @@ -158,6 +170,7 @@ We welcome contributions to the Forge library! Follow these guidelines to mainta See [models/README.md - Contributing](models/README.md#contributing) **Summary**: + 1. Define struct with validation rules 2. Implement `new` and `validate` methods 3. Add serialization traits (`Serialize`, `Deserialize`) @@ -168,6 +181,7 @@ See [models/README.md - Contributing](models/README.md#contributing) See [repositories/README.md - Contributing](repositories/README.md#contributing) **Summary**: + 1. Define repository trait with `Send + Sync` 2. Implement trait for `RedisXRepository` 3. Use `forge_shared::RedisClient` for operations @@ -178,6 +192,7 @@ See [repositories/README.md - Contributing](repositories/README.md#contributing) See [services/README.md - Contributing](services/README.md#contributing) **Summary**: + 1. Create service struct generic over repository 2. Implement constructor and business logic methods 3. Delegate data operations to repository @@ -186,29 +201,34 @@ See [services/README.md - Contributing](services/README.md#contributing) ### Best Practices #### Separation of Concerns + - **Models**: Only data structures and validation - **Repositories**: Only data persistence logic - **Services**: Only business logic and orchestration - **Shared**: Only common utilities and traits #### Error Handling + - Use `Result` for all fallible operations - Provide descriptive error messages - Propagate errors up the stack with `?` #### Testing + - **Models**: Test validation rules - **Repositories**: Test with mock Redis clients - **Services**: Test with mock repositories - **Integration**: Test full stack in extension layer #### Dependencies + - Models should have minimal dependencies - Repositories depend on models and shared - Services depend on repositories and models - Avoid circular dependencies #### Thread Safety + - All repository traits require `Send + Sync` - Services are stateless and thread-safe - Use appropriate synchronization primitives when needed diff --git a/lib/models/README.md b/lib/models/README.md index 2fbd5d6..8ed9d76 100644 --- a/lib/models/README.md +++ b/lib/models/README.md @@ -8,39 +8,40 @@ The `Actor` struct represents a player in the game. It contains all persistent d ### Fields -| Field | Type | Description | -| :--- | :--- | :--- | -| `uid` | `String` | Unique Steam ID (64-bit). Immutable. | -| `name` | `Option` | Player's display name. | -| `loadout` | `serde_json::Value` | JSON representation of the player's equipment. | -| `position` | `Option>` | `[x, y, z]` coordinates. | -| `direction` | `f64` | Compass direction (0-360). | -| `stance` | `Option` | Player stance (e.g., "STAND", "CROUCH"). | -| `email` | `String` | In-game email address (auto-generated). | -| `phone_number` | `String` | In-game phone number (auto-generated). | -| `bank` | `f64` | Money in the bank. | -| `cash` | `f64` | Money on hand. | -| `earnings` | `f64` | Total earnings. | -| `state` | `String` | Health/Status state (default: "HEALTHY"). | -| `holster` | `bool` | Whether the weapon is holstered. | -| `rank` | `Option` | Rank within an organization. | -| `organization` | `String` | ID of the organization the player belongs to. | -| `transactions` | `Vec` | History of financial transactions. | +| Field | Type | Description | +| :------------- | :----------------------- | :--------------------------------------------- | +| `uid` | `String` | Unique Steam ID (64-bit). Immutable. | +| `name` | `Option` | Player's display name. | +| `loadout` | `serde_json::Value` | JSON representation of the player's equipment. | +| `position` | `Option>` | `[x, y, z]` coordinates. | +| `direction` | `f64` | Compass direction (0-360). | +| `stance` | `Option` | Player stance (e.g., "STAND", "CROUCH"). | +| `email` | `String` | In-game email address (auto-generated). | +| `phone_number` | `String` | In-game phone number (auto-generated). | +| `bank` | `f64` | Money in the bank. | +| `cash` | `f64` | Money on hand. | +| `earnings` | `f64` | Total earnings. | +| `state` | `String` | Health/Status state (default: "HEALTHY"). | +| `holster` | `bool` | Whether the weapon is holstered. | +| `rank` | `Option` | Rank within an organization. | +| `organization` | `String` | ID of the organization the player belongs to. | +| `transactions` | `Vec` | History of financial transactions. | ### Validation Rules -- **UID**: Must be a 17-digit numeric string. -- **Name**: Max 50 characters, cannot be empty if set. -- **Position**: Must be an array of 3 finite numbers. -- **Direction**: Must be between 0.0 and 360.0. -- **Phone Number**: Must start with "0160" and be 10 digits long. -- **Email**: Must end with "@spearnet.mil". +- **UID**: Must be a 17-digit numeric string. +- **Name**: Max 50 characters, cannot be empty if set. +- **Position**: Must be an array of 3 finite numbers. +- **Direction**: Must be between 0.0 and 360.0. +- **Phone Number**: Must start with "0160" and be 10 digits long. +- **Email**: Must end with "@spearnet.mil". ### Arma Integration The `Actor` struct implements `FromArma` and `IntoArma` for seamless conversion between Rust structs and SQF values. -- **From Arma**: Expects a JSON string. -- **To Arma**: Returns a JSON string. + +- **From Arma**: Expects a JSON string. +- **To Arma**: Returns a JSON string. ## Organization Model @@ -48,20 +49,20 @@ The `Org` struct represents a guild, clan, or group of players. ### Fields -| Field | Type | Description | -| :--- | :--- | :--- | -| `id` | `String` | Unique identifier (slug). | -| `owner` | `String` | UID of the organization leader. | -| `name` | `String` | Display name of the organization. | -| `funds` | `f64` | Shared organization funds. | -| `reputation` | `i64` | Organization's reputation score. | +| Field | Type | Description | +| :----------- | :------- | :-------------------------------- | +| `id` | `String` | Unique identifier (slug). | +| `owner` | `String` | UID of the organization leader. | +| `name` | `String` | Display name of the organization. | +| `funds` | `f64` | Shared organization funds. | +| `reputation` | `i64` | Organization's reputation score. | ### Validation Rules -- **ID**: Alphanumeric and underscores only. Cannot be empty. -- **Owner**: Must be a valid 17-digit UID. -- **Name**: Max 100 characters, no control characters. -- **Funds**: Cannot be negative. +- **ID**: Alphanumeric and underscores only. Cannot be empty. +- **Owner**: Must be a valid 17-digit UID. +- **Name**: Max 100 characters, no control characters. +- **Funds**: Cannot be negative. ## Contributing @@ -72,8 +73,8 @@ We welcome contributions to the Forge Models crate! When adding a new model, ple To add a new model (e.g., `Vehicle`), follow these steps: 1. **Define the Struct**: Create a new file in `src/` (e.g., `src/vehicle.rs`) and define your struct. - - Derive `Debug`, `Clone`, `Serialize`, and `Deserialize`. - - Use `#[serde(default)]` for optional fields that should have default values. + - Derive `Debug`, `Clone`, `Serialize`, and `Deserialize`. + - Use `#[serde(default)]` for optional fields that should have default values. ```rust #[derive(Debug, Clone, Serialize, Deserialize)] @@ -86,6 +87,7 @@ To add a new model (e.g., `Vehicle`), follow these steps: ``` 2. **Implement `new`**: Provide a constructor that initializes the struct with valid defaults. + ```rust impl Vehicle { pub fn new(id: String, class_name: String) -> Result { @@ -101,6 +103,7 @@ To add a new model (e.g., `Vehicle`), follow these steps: ``` 3. **Implement `validate`**: Create a method to enforce business rules and data integrity. + ```rust impl Vehicle { pub fn validate(&self) -> Result<(), String> { @@ -116,6 +119,7 @@ To add a new model (e.g., `Vehicle`), follow these steps: ``` 4. **Implement Arma Traits**: Implement `FromArma` and `IntoArma` for SQF interoperability. + ```rust use arma_rs::{FromArma, IntoArma, Value}; diff --git a/lib/models/src/actor.rs b/lib/models/src/actor.rs index 7c37302..b6e7c2b 100644 --- a/lib/models/src/actor.rs +++ b/lib/models/src/actor.rs @@ -1,6 +1,6 @@ use arma_rs::{ FromArma, IntoArma, - loadout::{AssignedItems, Loadout as ArmaLoadout}, + loadout::{AssignedItems, InventoryItem, Loadout as ArmaLoadout}, }; use forge_shared::{ ActorValidationError, arma_value_to_json, generate_email, generate_phone_number, @@ -135,6 +135,9 @@ impl Actor { let uniform = loadout.uniform_mut(); uniform.set_class("U_BG_Guerrilla_6_1".to_string()); + let uniform_items = uniform.items_mut().unwrap(); + uniform_items.push(InventoryItem::new_item("FirstAidKit".to_string(), 1)); + loadout.set_headgear("H_Cap_blk_ION".to_string()); let mut items = AssignedItems::default(); diff --git a/lib/models/src/lib.rs b/lib/models/src/lib.rs index b3406aa..b710d4f 100644 --- a/lib/models/src/lib.rs +++ b/lib/models/src/lib.rs @@ -10,6 +10,6 @@ pub use actor::Actor; pub use bank::Bank; pub use garage::{Garage, HitPoints, Vehicle}; pub use locker::{Item, Locker}; -pub use org::{MemberSummary, Org}; +pub use org::{CreditLineSummary, MemberSummary, Org}; pub use v_garage::{VGarage, VehicleCategory}; pub use v_locker::{EquipmentCategory, VLocker}; diff --git a/lib/models/src/org.rs b/lib/models/src/org.rs index a6953d6..b4da3fa 100644 --- a/lib/models/src/org.rs +++ b/lib/models/src/org.rs @@ -1,6 +1,14 @@ use arma_rs::{FromArma, IntoArma}; use forge_shared::OrgValidationError; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreditLineSummary { + pub uid: String, + pub name: String, + pub amount: f64, +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Org { @@ -12,6 +20,8 @@ pub struct Org { pub funds: f64, #[serde(default)] pub reputation: i64, + #[serde(default)] + pub credit_lines: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -28,6 +38,7 @@ impl Org { name: name.into(), funds: 0.0, reputation: 0, + credit_lines: HashMap::new(), }; org.validate()?; @@ -65,6 +76,26 @@ impl Org { return Err(OrgValidationError::InvalidName(self.name.clone())); } + for (uid, credit_line) in &self.credit_lines { + let resolved_uid = if credit_line.uid.trim().is_empty() { + uid + } else { + &credit_line.uid + }; + + if !resolved_uid.chars().all(|c| c.is_numeric()) || resolved_uid.len() != 17 { + return Err(OrgValidationError::InvalidCreditLineUid( + resolved_uid.to_string(), + )); + } + + if credit_line.amount < 0.0 { + return Err(OrgValidationError::NegativeCreditLine( + resolved_uid.to_string(), + )); + } + } + Ok(()) } diff --git a/lib/models/src/v_garage.rs b/lib/models/src/v_garage.rs index 94e1101..6e54df9 100644 --- a/lib/models/src/v_garage.rs +++ b/lib/models/src/v_garage.rs @@ -23,8 +23,12 @@ pub struct VGarage { impl VGarage { pub fn new() -> Self { + Self::default_unlocks() + } + + fn default_unlocks() -> Self { Self { - cars: Vec::new(), + cars: vec!["B_Quadbike_01_F".to_string()], armor: Vec::new(), helis: Vec::new(), planes: Vec::new(), @@ -79,12 +83,6 @@ impl VGarage { } } -impl Default for VGarage { - fn default() -> Self { - Self::new() - } -} - impl FromArma for VGarage { fn from_arma(s: String) -> Result { serde_json::from_str(&s) diff --git a/lib/models/src/v_locker.rs b/lib/models/src/v_locker.rs index 9c2bd0a..4ad24e4 100644 --- a/lib/models/src/v_locker.rs +++ b/lib/models/src/v_locker.rs @@ -19,11 +19,42 @@ pub struct VLocker { impl VLocker { pub fn new() -> Self { + Self::default_unlocks() + } + + fn default_unlocks() -> Self { Self { - items: Vec::new(), - weapons: Vec::new(), - magazines: Vec::new(), - backpacks: Vec::new(), + items: vec![ + "FirstAidKit".to_string(), + "G_Combat".to_string(), + "H_Cap_blk_ION".to_string(), + "H_HelmetB".to_string(), + "ItemCompass".to_string(), + "ItemGPS".to_string(), + "ItemMap".to_string(), + "ItemRadio".to_string(), + "ItemWatch".to_string(), + "U_IG_Guerrilla_6_1".to_string(), + "V_TacVest_oli".to_string(), + ], + weapons: vec!["arifle_MX_F".to_string(), "hgun_P07_F".to_string()], + magazines: vec![ + "16Rnd_9x21_Mag".to_string(), + "30Rnd_65x39_caseless_black_mag".to_string(), + "Chemlight_blue".to_string(), + "Chemlight_green".to_string(), + "Chemlight_red".to_string(), + "Chemlight_yellow".to_string(), + "HandGrenade".to_string(), + "SmokeShell".to_string(), + "SmokeShellBlue".to_string(), + "SmokeShellGreen".to_string(), + "SmokeShellOrange".to_string(), + "SmokeShellPurple".to_string(), + "SmokeShellRed".to_string(), + "SmokeShellYellow".to_string(), + ], + backpacks: vec!["B_AssaultPack_rgr".to_string()], } } diff --git a/lib/repositories/README.md b/lib/repositories/README.md index f58a89d..d9e3c45 100644 --- a/lib/repositories/README.md +++ b/lib/repositories/README.md @@ -17,7 +17,9 @@ graph TD ``` ### Dual Storage Strategy + The implementation uses a dual storage strategy in Redis to optimize for different access patterns: + - **Hash Maps (`HMSET`/`HGETALL`):** Used for entity data (Actors, Organizations) to allow O(1) access to specific fields and efficient partial updates. - **Sets (`SADD`/`SMEMBERS`):** Used for relationships (e.g., Organization Members) to ensure uniqueness and provide efficient membership testing. @@ -35,7 +37,9 @@ The implementation uses a dual storage strategy in Redis to optimize for differe The `ActorRepository` handles persistence for player data. ### Storage Format + Actors are stored in Redis as hash maps: + ```text actor:{uid} -> Hash { "uid": "76561198123456789", @@ -84,6 +88,7 @@ async fn example_usage(repo: &R) -> Result<(), String> { The `OrgRepository` handles persistence for organizations (guilds/clans) and their members. ### Storage Format + - **Org Data:** `org:{org_id}` (Hash) - **Members:** `org:{org_id}:members` (Set) @@ -127,13 +132,16 @@ async fn example_usage(repo: &R) -> Result<(), String> { ## Performance & Implementation Details ### Atomicity + - **Upserts:** `create` and `update` operations use `HMSET` which is atomic. This means either all fields are updated or none are. - **Schema Evolution:** New fields added to the Rust structs are automatically persisted to Redis. Old fields in Redis that are no longer in the struct are **preserved** (not deleted) during updates, allowing for safe backward compatibility. ### Thread Safety + All repository implementations are `Send + Sync`, allowing them to be safely shared across threads. The underlying `RedisClient` handles connection pooling and concurrent access. ### Error Handling + Repositories return `Result` (or custom error types) to propagate database failures, serialization errors, or validation issues up to the service layer. ## Contributing @@ -154,6 +162,7 @@ To add a new repository (e.g., `ItemRepository`), follow these steps: } ``` 3. **Implement for Redis**: Implement the trait for a generic `RedisClient`. Use `forge_shared` helpers for value conversion if needed. + ```rust use forge_shared::{RedisClient, parse_json_value, parse_redis_value}; @@ -178,6 +187,7 @@ To add a new repository (e.g., `ItemRepository`), follow these steps: // ... other methods } ``` + 4. **Register the Module**: Add your new module to `src/lib.rs` and export the trait and implementation. ```rust pub mod item; @@ -186,12 +196,12 @@ To add a new repository (e.g., `ItemRepository`), follow these steps: ### Testing -- **Integration Tests**: Write integration tests that use a real Redis instance (if available) or a mock. -- **Mocking**: For unit testing services, you don't need to test the repository implementation itself, but you should ensure the repository trait is easy to mock. +- **Integration Tests**: Write integration tests that use a real Redis instance (if available) or a mock. +- **Mocking**: For unit testing services, you don't need to test the repository implementation itself, but you should ensure the repository trait is easy to mock. ### Best Practices -- **Abstraction**: Keep the repository trait agnostic of the underlying database technology (e.g., don't expose Redis-specific types in the trait signature). -- **Serialization**: Handle serialization/deserialization within the repository implementation. The service layer should work with domain models, not raw JSON or database rows. -- **Keyspace**: Use a consistent naming convention for Redis keys (e.g., `entity:id`). -- **Atomicity**: Use Redis transactions or atomic commands where possible to ensure data consistency. +- **Abstraction**: Keep the repository trait agnostic of the underlying database technology (e.g., don't expose Redis-specific types in the trait signature). +- **Serialization**: Handle serialization/deserialization within the repository implementation. The service layer should work with domain models, not raw JSON or database rows. +- **Keyspace**: Use a consistent naming convention for Redis keys (e.g., `entity:id`). +- **Atomicity**: Use Redis transactions or atomic commands where possible to ensure data consistency. diff --git a/lib/repositories/src/org.rs b/lib/repositories/src/org.rs index 2997eea..60340cd 100644 --- a/lib/repositories/src/org.rs +++ b/lib/repositories/src/org.rs @@ -127,6 +127,16 @@ impl OrgRepository for RedisOrgRepository { } // Reconstruct Org from JSON object + if matches!( + json_map.get("credit_lines"), + Some(serde_json::Value::Array(lines)) if lines.is_empty() + ) { + json_map.insert( + "credit_lines".to_string(), + serde_json::Value::Object(serde_json::Map::new()), + ); + } + let json_obj = serde_json::Value::Object(json_map); match serde_json::from_value::(json_obj) { Ok(org) => Ok(Some(org)), @@ -147,15 +157,21 @@ impl OrgRepository for RedisOrgRepository { /// Permanently deletes an organization and all associated data from Redis. /// - /// Removes the organization hash and the associated members list. + /// Removes the organization hash and related subordinate keys. /// This operation is irreversible. fn delete(&self, id: &str) -> Result<(), String> { - // Generate Redis key using organization ID - let redis_key = format!("org:{}", id); + let redis_keys = [ + format!("org:{}", id), + format!("org:{}:members", id), + format!("org:{}:assets", id), + format!("org:{}:fleet", id), + ]; - // Delete the organization hash key from Redis - // Note: This does NOT delete member data stored separately - self.client.delete_key(redis_key) + for redis_key in redis_keys { + self.client.delete_key(redis_key)?; + } + + Ok(()) } /// Checks if an organization exists in Redis without retrieving the data. diff --git a/lib/services/README.md b/lib/services/README.md index e0b25cc..78b64a1 100644 --- a/lib/services/README.md +++ b/lib/services/README.md @@ -31,6 +31,7 @@ graph TD The `ActorService` manages player lifecycle and state. ### Key Features + - **Get-or-Create:** Automatically creates new actors with default values if they don't exist. - **JSON Integration:** Directly accepts JSON strings for updates and creation. - **Partial Updates:** Supports updating specific fields without overwriting the entire actor. @@ -72,6 +73,7 @@ service.delete_actor("76561198123456789".to_string())?; The `OrgService` manages organization (guild/clan) lifecycle and member management. ### Key Features + - **Get-or-Create:** Automatically creates new organizations with default values if they don't exist. - **Member Management:** Handles adding and removing members with validation. - **Duplicate Prevention:** Ensures unique organization IDs and member UIDs. diff --git a/lib/services/src/org.rs b/lib/services/src/org.rs index 58d1257..05bddee 100644 --- a/lib/services/src/org.rs +++ b/lib/services/src/org.rs @@ -5,8 +5,9 @@ //! //! For full documentation, architecture, and examples, see the [crate README](../README.md). -use forge_models::{MemberSummary, Org}; +use forge_models::{CreditLineSummary, MemberSummary, Org}; use forge_repositories::OrgRepository; +use std::collections::HashMap; /// Service layer implementation for organization business logic and operations. /// @@ -24,6 +25,31 @@ pub struct OrgService { } impl OrgService { + fn normalize_org_value( + mut org_value: serde_json::Value, + key_override: Option, + ) -> Result { + let org_object = org_value + .as_object_mut() + .ok_or_else(|| "Org payload must be a JSON object".to_string())?; + + if let Some(key) = key_override { + org_object.insert("id".to_string(), serde_json::Value::String(key)); + } + + if matches!( + org_object.get("credit_lines"), + Some(serde_json::Value::Array(lines)) if lines.is_empty() + ) { + org_object.insert( + "credit_lines".to_string(), + serde_json::Value::Object(serde_json::Map::new()), + ); + } + + serde_json::from_value::(org_value).map_err(|e| format!("Invalid Org JSON: {}", e)) + } + /// Creates a new organization service with the provided repository. /// /// The repository must be initialized and ready for use. @@ -36,12 +62,9 @@ impl OrgService { /// Handles validation, duplicate checking, and persistence. /// See [crate README](../README.md) for JSON format and business rules. pub fn create_org(&self, key: String, json_data: String) -> Result { - // Parse JSON data to Org struct - let mut org: Org = + let org_value: serde_json::Value = serde_json::from_str(&json_data).map_err(|e| format!("Invalid Org JSON: {}", e))?; - - // Override ID with the provided parameter (ensures consistency) - org.id = key; + let org = Self::normalize_org_value(org_value, Some(key))?; // Validate organization name is not empty if org.name.trim().is_empty() { @@ -59,21 +82,10 @@ impl OrgService { Ok(org) } - /// Retrieves an organization by its unique identifier with automatic fallback to default. - /// - /// Implements a fallback pattern: if the organization doesn't exist, the - /// organization with ID "default" is retrieved from the repository. pub fn get_org(&self, key: String) -> Result { - // Attempt to retrieve organization from repository - match self.repository.get_by_id(&key)? { - // Organization found - return it - Some(org) => Ok(org), - // Organization not found - retrieve the default organization instead - None => self - .repository - .get_by_id("default")? - .ok_or_else(|| "Default organization not found".to_string()), - } + self.repository + .get_by_id(&key)? + .ok_or_else(|| format!("Organization with ID '{}' not found", key)) } /// Updates an existing organization with new data from JSON. @@ -88,7 +100,7 @@ impl OrgService { }; // Parse and validate JSON update data - let update_data: serde_json::Value = + let mut update_data: serde_json::Value = serde_json::from_str(&json_update).map_err(|e| format!("Invalid JSON: {}", e))?; // Ensure update data is a JSON object @@ -96,6 +108,13 @@ impl OrgService { return Err("Update data must be a JSON object".to_string()); } + if matches!( + update_data.get("credit_lines"), + Some(serde_json::Value::Array(lines)) if lines.is_empty() + ) { + update_data["credit_lines"] = serde_json::Value::Object(serde_json::Map::new()); + } + // Create a temporary copy to safely apply updates with validation let mut updated_org = org.clone(); @@ -138,6 +157,21 @@ impl OrgService { return Err("Reputation must be an integer".to_string()); } } + "credit_lines" => { + if value.is_null() { + updated_org.credit_lines = HashMap::new(); + } else { + updated_org.credit_lines = serde_json::from_value::< + HashMap, + >(value.clone()) + .map_err(|e| { + format!( + "Credit lines must be an object of member credit entries: {}", + e + ) + })?; + } + } _ => { return Err(format!("Unknown field: {}", field)); } @@ -163,16 +197,7 @@ impl OrgService { /// /// Irreversible operation. Delegates to repository. pub fn delete_org(&self, key: String) -> Result<(), String> { - let redis_key = format!("org:{}", key); - let assets_key = format!("org:{}:assets", key); - let members_key = format!("org:{}:members", key); - - // Delegate deletion to repository layer - self.repository.delete(&redis_key)?; - self.repository.delete(&assets_key)?; - self.repository.delete(&members_key)?; - - Ok(()) + self.repository.delete(&key) } /// Checks if an organization exists in the system. diff --git a/lib/shared/src/validation.rs b/lib/shared/src/validation.rs index 5aa69eb..2c2152d 100644 --- a/lib/shared/src/validation.rs +++ b/lib/shared/src/validation.rs @@ -80,9 +80,11 @@ pub enum OrgValidationError { EmptyOwner, EmptyName, NegativeFunds, + NegativeCreditLine(String), InvalidId(String), InvalidOwner(String), InvalidName(String), + InvalidCreditLineUid(String), } impl fmt::Display for OrgValidationError { @@ -94,6 +96,9 @@ impl fmt::Display for OrgValidationError { OrgValidationError::NegativeFunds => { write!(f, "Organization funds cannot be negative") } + OrgValidationError::NegativeCreditLine(uid) => { + write!(f, "Credit line for '{}' cannot be negative", uid) + } OrgValidationError::InvalidId(id) => write!( f, "Invalid organization ID '{}' - must contain only alphanumeric characters and underscores", @@ -107,6 +112,11 @@ impl fmt::Display for OrgValidationError { "Invalid organization name '{}' - cannot exceed 100 characters or contain control characters", name ), + OrgValidationError::InvalidCreditLineUid(uid) => write!( + f, + "Invalid credit line UID '{}' - must be a 17-digit Steam ID", + uid + ), } } } diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d65ee4d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,699 @@ +{ + "name": "forge-webui", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "forge-webui", + "devDependencies": { + "html-minifier-terser": "^7.2.0", + "lightningcss": "^1.29.3", + "postcss": "^8.5.6", + "postcss-nested": "^7.0.2", + "prettier": "^3.6.2", + "terser": "^5.44.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/clean-css": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-minifier-terser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz", + "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "~5.3.2", + "commander": "^10.0.0", + "entities": "^4.4.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.15.1" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-nested": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-7.0.2.tgz", + "integrity": "sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/terser": { + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..637fc5d --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "forge-webui", + "private": true, + "devDependencies": { + "html-minifier-terser": "^7.2.0", + "lightningcss": "^1.29.3", + "postcss": "^8.5.6", + "postcss-nested": "^7.0.2", + "prettier": "^3.6.2", + "terser": "^5.44.0" + }, + "scripts": { + "build:webui": "node tools/build-webui.mjs" + } +} diff --git a/tools/build-webui.mjs b/tools/build-webui.mjs new file mode 100644 index 0000000..900d143 --- /dev/null +++ b/tools/build-webui.mjs @@ -0,0 +1,358 @@ +import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { minify as minifyHtml } from "html-minifier-terser"; +import { transform as transformCss } from "lightningcss"; +import postcss from "postcss"; +import postcssNested from "postcss-nested"; +import { minify as minifyJs } from "terser"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, ".."); +const commonUiSrcDir = "arma/client/addons/common/ui/src"; +const commonUiSiteDir = "arma/client/addons/common/ui/_site"; +const clientAddonsDir = path.join(rootDir, "arma/client/addons"); + +function toRepoRelative(absolutePath) { + return path.relative(rootDir, absolutePath).replace(/\\/g, "/"); +} + +function resolveFromRoot(...segments) { + return toRepoRelative(path.join(rootDir, ...segments)); +} + +function resolveFromConfigDir(configDir, relativePath) { + return toRepoRelative(path.resolve(configDir, relativePath)); +} + +const commonJsBundles = [ + { + name: "Forge Web UI runtime", + output: resolveFromRoot(commonUiSiteDir, "forge-webui.js"), + sources: [ + "runtime.js", + "host.js", + "bridge.js", + "app.js", + "windowTitleBar.js", + "index.js", + ].map((relativePath) => resolveFromRoot(commonUiSrcDir, relativePath)), + }, + { + name: "Forge Web UI site loader", + output: resolveFromRoot(commonUiSiteDir, "forge-site-loader.js"), + sources: [resolveFromRoot(commonUiSrcDir, "siteLoader.js")], + }, +]; +const commonFormatSourceTargets = [resolveFromRoot(commonUiSrcDir)]; + +function unique(values) { + return Array.from(new Set(values)); +} + +async function readSource(relativePath) { + const absolutePath = path.join(rootDir, relativePath); + return readFile(absolutePath, "utf8"); +} + +async function writeBundle(outputRelativePath, content) { + const outputPath = path.join(rootDir, outputRelativePath); + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, content, "utf8"); +} + +async function cleanOutputDirs(outputDirs) { + const uniqueDirs = unique(outputDirs).filter(Boolean); + + await Promise.all( + uniqueDirs.map(async (relativeDir) => { + const absoluteDir = path.join(rootDir, relativeDir); + await rm(absoluteDir, { force: true, recursive: true }); + await mkdir(absoluteDir, { recursive: true }); + }), + ); +} + +async function buildJsBundle({ name, output, sources }) { + const chunks = await Promise.all(sources.map(readSource)); + const bundleSource = chunks.join("\n\n"); + const result = await minifyJs(bundleSource, { + compress: true, + mangle: true, + format: { + comments: false, + }, + }); + + if (!result?.code) { + throw new Error(`Failed to minify JavaScript bundle for ${name}.`); + } + + await writeBundle(output, result.code); + console.log(`Built ${output}`); +} + +async function buildCssBundle({ name, output, sources }) { + const chunks = await Promise.all(sources.map(readSource)); + const nestedResult = await postcss([postcssNested]).process( + chunks.join("\n\n"), + { + from: undefined, + }, + ); + const result = transformCss({ + filename: output, + code: Buffer.from(nestedResult.css), + minify: true, + }); + + await writeBundle(output, result.code.toString("utf8")); + console.log(`Built ${output}`); +} + +function renderSiteIndex({ title, siteConfig }) { + const configJson = JSON.stringify(siteConfig, null, 16) + .replace(/^/gm, " ".repeat(12)) + .trimStart(); + + return ` + + + + + ${title} + + + + +
+ + +`; +} + +async function buildHtmlPage({ name, output, title, siteConfig }) { + const html = renderSiteIndex({ title, siteConfig }); + const minifiedHtml = await minifyHtml(html, { + collapseBooleanAttributes: true, + collapseWhitespace: true, + minifyCSS: true, + minifyJS: true, + removeComments: true, + removeRedundantAttributes: true, + }); + + await writeBundle(output, minifiedHtml); + console.log(`Built ${output}`); +} + +async function pathExists(absolutePath) { + try { + await stat(absolutePath); + return true; + } catch { + return false; + } +} + +async function runPrettier(targets) { + const uniqueTargets = unique(targets).filter(Boolean); + if (uniqueTargets.length === 0) { + return; + } + + console.log(`Formatting ${uniqueTargets.length} Web UI target(s) with Prettier`); + + await new Promise((resolve, reject) => { + const quotedTargets = uniqueTargets.map((target) => + `"${String(target).replace(/"/g, '\\"')}"`, + ); + const command = `npx prettier --write --ignore-unknown ${quotedTargets.join(" ")}`; + const child = spawn(command, [], { + cwd: rootDir, + stdio: "inherit", + shell: true, + }); + + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolve(); + return; + } + + reject( + new Error(`Prettier failed with exit code ${code ?? "unknown"}.`), + ); + }); + }); +} + +async function discoverUiConfigs() { + const addons = await readdir(clientAddonsDir, { withFileTypes: true }); + const configPaths = []; + + for (const entry of addons) { + if (!entry.isDirectory()) { + continue; + } + + const configPath = path.join( + clientAddonsDir, + entry.name, + "ui", + "ui.config.mjs", + ); + + try { + const configStat = await stat(configPath); + if (configStat.isFile()) { + configPaths.push(configPath); + } + } catch { + // UI config is optional per addon. + } + } + + configPaths.sort((left, right) => left.localeCompare(right)); + return configPaths; +} + +async function loadUiConfig(absoluteConfigPath) { + const configModule = await import(pathToFileURL(absoluteConfigPath).href); + const config = configModule.default; + + if (!config || !config.addonName || !config.outputDir || !config.site) { + throw new Error( + `Invalid UI config at ${toRepoRelative(absoluteConfigPath)}.`, + ); + } + + const configDir = path.dirname(absoluteConfigPath); + const configRelativePath = toRepoRelative(absoluteConfigPath); + const outputDir = resolveFromConfigDir(configDir, config.outputDir); + const srcDirPath = path.join(configDir, "src"); + const formatSourceTargets = [configRelativePath]; + + if (await pathExists(srcDirPath)) { + formatSourceTargets.push(toRepoRelative(srcDirPath)); + } + + const jsBundles = (config.jsBundles || []).map((bundle) => ({ + name: bundle.name, + output: resolveFromConfigDir(configDir, path.join(config.outputDir, bundle.output)), + sources: (bundle.sources || []).map((source) => + resolveFromConfigDir(configDir, source), + ), + })); + const cssBundles = (config.cssBundles || []).map((bundle) => ({ + name: bundle.name, + output: resolveFromConfigDir(configDir, path.join(config.outputDir, bundle.output)), + sources: (bundle.sources || []).map((source) => + resolveFromConfigDir(configDir, source), + ), + })); + const htmlPage = { + name: `${config.addonName} UI index`, + output: resolveFromConfigDir(configDir, path.join(config.outputDir, "index.html")), + title: config.title, + siteConfig: { + addonName: config.addonName, + logLabel: config.logLabel || `${config.addonName} UI`, + ...config.site, + }, + }; + + return { + outputDir, + jsBundles, + cssBundles, + htmlPage, + formatSourceTargets, + }; +} + +async function collectUiBuildArtifacts() { + const configPaths = await discoverUiConfigs(); + const uiConfigs = await Promise.all(configPaths.map(loadUiConfig)); + + return { + outputDirs: uiConfigs.map((config) => config.outputDir), + jsBundles: uiConfigs.flatMap((config) => config.jsBundles), + cssBundles: uiConfigs.flatMap((config) => config.cssBundles), + htmlPages: uiConfigs.map((config) => config.htmlPage), + formatSourceTargets: uiConfigs.flatMap( + (config) => config.formatSourceTargets, + ), + }; +} + +async function build() { + const uiArtifacts = await collectUiBuildArtifacts(); + const commonOutputDirs = [resolveFromRoot(commonUiSiteDir)]; + + await runPrettier([ + ...commonFormatSourceTargets, + ...uiArtifacts.formatSourceTargets, + ]); + + await cleanOutputDirs([...commonOutputDirs, ...uiArtifacts.outputDirs]); + + await Promise.all([ + ...commonJsBundles.map(buildJsBundle), + ...uiArtifacts.jsBundles.map(buildJsBundle), + ]); + await Promise.all(uiArtifacts.cssBundles.map(buildCssBundle)); + await Promise.all(uiArtifacts.htmlPages.map(buildHtmlPage)); +} + +build().catch((error) => { + console.error("Failed to build Forge Web UI bundles."); + console.error(error); + process.exitCode = 1; +});