diff --git a/.github/workflows/cs.yml b/.github/workflows/cs.yml new file mode 100644 index 0000000..69431b2 --- /dev/null +++ b/.github/workflows/cs.yml @@ -0,0 +1,29 @@ +name: Run CS on pull requests + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +jobs: + cs: + name: Run composer cs + runs-on: ubuntu-latest + strategy: + matrix: + php-version: ['8.3'] + + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Run CS script + run: composer run-script cs + + - name: Run unit tests + run: composer run-script unit diff --git a/Design Docs/02-backend-architecture.md b/Design Docs/02-backend-architecture.md index 7e55111..68786da 100644 --- a/Design Docs/02-backend-architecture.md +++ b/Design Docs/02-backend-architecture.md @@ -1024,35 +1024,34 @@ backend-php/ │ └── server.php ├── src/ │ ├── Server/ -│ │ ├── HttpServer.php -│ │ ├── WebSocketServer.php -│ │ └── Router.php +│ │ ├── Http.php +│ │ ├── WebSocket.php │ ├── Auth/ -│ │ ├── AuthHandler.php +│ │ ├── Handler.php │ │ ├── TokenService.php -│ │ └── AuthMiddleware.php +│ │ └── Middleware.php │ ├── Account/ -│ │ ├── AccountHandler.php -│ │ ├── AccountService.php -│ │ └── AccountRepository.php +│ │ ├── Handler.php +│ │ ├── Service.php +│ │ └── Repository.php │ ├── Character/ -│ │ ├── CharacterHandler.php -│ │ ├── CharacterService.php -│ │ └── CharacterRepository.php +│ │ ├── Handler.php +│ │ ├── Service.php +│ │ └── Repository.php │ ├── Contract/ -│ │ ├── ContractHandler.php -│ │ ├── ContractService.php -│ │ ├── ContractRepository.php -│ │ ├── ContractResolver.php -│ │ └── ContractTypes.php +│ │ ├── Handler.php +│ │ ├── Service.php +│ │ ├── Repository.php +│ │ ├── Resolver.php +│ │ └── Types.php │ ├── Research/ -│ │ ├── ResearchHandler.php -│ │ ├── ResearchService.php +│ │ ├── Handler.php +│ │ ├── Service.php │ │ ├── TrialService.php -│ │ └── ResearchRepository.php +│ │ └── Repository.php │ ├── World/ -│ │ ├── WorldHandler.php -│ │ ├── WorldTicker.php +│ │ ├── Handler.php +│ │ ├── Ticker.php │ │ ├── ZoneService.php │ │ ├── BuildingService.php │ │ └── ManaService.php @@ -1060,15 +1059,15 @@ backend-php/ │ │ ├── MarketHandler.php │ │ └── MarketService.php │ ├── Governance/ -│ │ ├── GovernanceHandler.php +│ │ ├── Handler.php │ │ ├── ElectionService.php │ │ └── PolicyService.php │ ├── Raid/ -│ │ ├── RaidScheduler.php -│ │ ├── RaidExecutor.php -│ │ └── RaidScaling.php +│ │ ├── Scheduler.php +│ │ ├── Executor.php +│ │ └── Scaling.php │ ├── Skill/ -│ │ └── SkillCalculator.php +│ │ └── Calculator.php │ └── Database/ │ ├── ConnectionPool.php │ └── TransactionHelper.php diff --git a/Design Docs/03-frontend-architecture.md b/Design Docs/03-frontend-architecture.md index 5e82f0f..c1e7fb5 100644 --- a/Design Docs/03-frontend-architecture.md +++ b/Design Docs/03-frontend-architecture.md @@ -292,11 +292,11 @@ export const governance = { type EventHandler = (data: any) => void class WebSocketManager { - private ws: WebSocket | null = null - private handlers: Map> = new Map() - private subscriptions: Set = new Set() - private reconnectTimer: number | null = null - private url: string + protected ws: WebSocket | null = null + protected handlers: Map> = new Map() + protected subscriptions: Set = new Set() + protected reconnectTimer: number | null = null + protected url: string constructor(url: string) { this.url = url @@ -345,7 +345,7 @@ class WebSocketManager { return () => this.handlers.get(eventType)?.delete(handler) } - private scheduleReconnect(token: string) { + protected scheduleReconnect(token: string) { if (this.reconnectTimer) return this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null diff --git a/composer.json b/composer.json index 6fd82be..0d8f443 100644 --- a/composer.json +++ b/composer.json @@ -8,8 +8,11 @@ } ], "require": { - "php": "^8.2.0", - "team-reflex/discord-php": "^10.45.22" + "php": "^8.3.0", + "team-reflex/discord-php": "^10.45.22", + "monolog/monolog": "^3.0", + "sharkk/router": "*", + "firebase/php-jwt": "^6.8" }, "require-dev": { "symfony/var-dumper": "*", @@ -22,7 +25,7 @@ }, "scripts": { "cs": ["./vendor/bin/php-cs-fixer fix"], - "unit": ["./vendor/bin/phpunit"] + "unit": ["./vendor/bin/pest"] }, "minimum-stability": "dev", "prefer-stable": true, @@ -34,7 +37,8 @@ }, "autoload": { "psr-4": { - "IA\\": "src/IA/" + "IA\\": "src/IA/", + "BackendPhp\\": "src/IA/backend/" } } } diff --git a/src/IA/backend/Account/Handler.php b/src/IA/backend/Account/Handler.php new file mode 100644 index 0000000..b759d9a --- /dev/null +++ b/src/IA/backend/Account/Handler.php @@ -0,0 +1,32 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Account; + +use Psr\Http\Message\ServerRequestInterface; +use React\Promise\PromiseInterface; + +class Handler +{ + protected Service $service; + + public function __construct(Service $service) + { + $this->service = $service; + } + + public function getAccount(ServerRequestInterface $request): PromiseInterface + { + return $this->service->getAccount($request->getAttribute('auth') ?? null); + } +} diff --git a/src/IA/backend/Account/Repository.php b/src/IA/backend/Account/Repository.php new file mode 100644 index 0000000..1f0d1f1 --- /dev/null +++ b/src/IA/backend/Account/Repository.php @@ -0,0 +1,26 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Account; + +use React\Promise\PromiseInterface; +use function React\Promise\resolve; + +class Repository +{ + public function findById(string $id): PromiseInterface + { + // Placeholder for async DB lookup. + return resolve(null); + } +} diff --git a/src/IA/backend/Account/Service.php b/src/IA/backend/Account/Service.php new file mode 100644 index 0000000..f8766b7 --- /dev/null +++ b/src/IA/backend/Account/Service.php @@ -0,0 +1,35 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Account; + +use React\Promise\PromiseInterface; +use function React\Promise\resolve; + +class Service +{ + protected Repository $repo; + + public function __construct(Repository $repo) + { + $this->repo = $repo; + } + + public function getAccount(?array $auth): PromiseInterface + { + // Return account object for authenticated user + $res = ['id' => $auth['sub'] ?? null, 'banked' => 0]; + + return resolve($res); + } +} diff --git a/src/IA/backend/Api/AbstractRepository.php b/src/IA/backend/Api/AbstractRepository.php new file mode 100644 index 0000000..caf06ec --- /dev/null +++ b/src/IA/backend/Api/AbstractRepository.php @@ -0,0 +1,173 @@ +http = $container->get('http'); + $this->factory = $container->get('factory'); + $this->vars = $vars; + } + + /** + * Default freshen implementation returning serialized collection. + */ + public function freshen(array $params = []): array + { + // Ignore $params by default; concrete repositories may override behavior. + return $this->jsonSerialize(); + } + + /** + * Default fetch implementation using the collection's discrim. + */ + public function fetch(string $id): array|null + { + $item = $this->get($this->discrim, $id); + if ($item === null) { + return null; + } + + // If the item is an object with jsonSerialize, try to convert to array + if (is_object($item) && method_exists($item, 'jsonSerialize')) { + return $item->jsonSerialize(); + } + + return is_array($item) ? $item : (array) $item; + } + + /** + * Concrete repositories must implement resource mutations. + */ + abstract public function save(array $data): array; + + abstract public function update(string $id, array $data): array; + + abstract public function delete(string $id): bool; + + protected function request(string $method, string $path, ?array $body = null): array + { + $req = ['method' => strtoupper($method), 'path' => $path]; + if ($body !== null) { + $req['body'] = $body; + } + return $req; + } + + protected function withQuery(string $path, array $params = []): string + { + if (empty($params)) { + return $path; + } + + return $path . '?' . http_build_query($params); + } + + public function getClient() + { + return $this->http; + } + + /** + * Bind placeholder tokens in an endpoint template. + * Example: bindPath('/items/:id', ['id' => 123]) => '/items/123' + */ + protected function bindPath(string $template, array $params = []): string + { + // Prefer the project's Endpoint class if provided, then fall back to Discord's. + if (class_exists(\BackendPhp\Api\Endpoint::class)) { + $endpoint = new \BackendPhp\Api\Endpoint($template); + if (! empty($params)) { + $endpoint->bindAssoc($params); + } + + return (string) $endpoint; + } + + if (class_exists(\Discord\Http\Endpoint::class)) { + $endpoint = new \Discord\Http\Endpoint($template); + if (! empty($params)) { + // Bind associative params where keys match :placeholders + $endpoint->bindAssoc($params); + } + + return (string) $endpoint; + } + + // Fallback: simple placeholder replacement + $path = $template; + foreach ($params as $key => $value) { + $path = str_replace(':' . $key, urlencode((string) $value), $path); + } + + return $path; + } +} diff --git a/src/IA/backend/Api/Endpoint.php b/src/IA/backend/Api/Endpoint.php new file mode 100644 index 0000000..1452b73 --- /dev/null +++ b/src/IA/backend/Api/Endpoint.php @@ -0,0 +1,153 @@ +endpoint = $endpoint; + + if (preg_match_all(self::REGEX, $endpoint, $vars)) { + $this->vars = $vars[1] ?? []; + } + } +} diff --git a/src/IA/backend/Api/Repositories/Account.php b/src/IA/backend/Api/Repositories/Account.php new file mode 100644 index 0000000..791b76b --- /dev/null +++ b/src/IA/backend/Api/Repositories/Account.php @@ -0,0 +1,41 @@ +request('GET', Endpoint::ACCOUNT); + } + + public function skillpoints(): array + { + return $this->request('GET', Endpoint::ACCOUNT_SKILLPOINTS); + } + + public function library(): array + { + return $this->request('GET', Endpoint::ACCOUNT_LIBRARY); + } + + public function save(array $data): array + { + throw new \BadMethodCallException('Save not supported on Account repository'); + } + + public function update(string $id, array $data): array + { + throw new \BadMethodCallException('Update not supported on Account repository'); + } + + public function delete(string $id): bool + { + throw new \BadMethodCallException('Delete not supported on Account repository'); + } +} diff --git a/src/IA/backend/Api/Repositories/Auth.php b/src/IA/backend/Api/Repositories/Auth.php new file mode 100644 index 0000000..da49fc7 --- /dev/null +++ b/src/IA/backend/Api/Repositories/Auth.php @@ -0,0 +1,42 @@ +request('POST', Endpoint::AUTH_LOGIN, ['email' => $email, 'password' => $password]); + } + + public function register(string $email, string $password): array + { + return $this->request('POST', Endpoint::AUTH_REGISTER, ['email' => $email, 'password' => $password]); + } + + public function refresh(string $token): array + { + return $this->request('POST', Endpoint::AUTH_REFRESH, ['token' => $token]); + } + + public function save(array $data): array + { + // Map to register when creating via generic interface + return $this->register($data['email'] ?? '', $data['password'] ?? ''); + } + + public function update(string $id, array $data): array + { + throw new \BadMethodCallException('Update not supported on Auth repository'); + } + + public function delete(string $id): bool + { + throw new \BadMethodCallException('Delete not supported on Auth repository'); + } +} diff --git a/src/IA/backend/Api/Repositories/Characters.php b/src/IA/backend/Api/Repositories/Characters.php new file mode 100644 index 0000000..8da7412 --- /dev/null +++ b/src/IA/backend/Api/Repositories/Characters.php @@ -0,0 +1,108 @@ +withQuery(Endpoint::CHARACTERS, $params); + return $this->request('GET', $path); + } + + public function fetch(string $id): array + { + $path = $this->bindPath(Endpoint::CHARACTER, ['id' => $id]); + return $this->request('GET', $path); + } + + public function save(array $data): array + { + if (! empty($data[$this->discrim])) { + $path = $this->bindPath(Endpoint::CHARACTER, [$this->discrim => $data[$this->discrim]]); + return $this->request('PATCH', $path, $data); + } + + return $this->request('POST', Endpoint::CHARACTERS, $data); + } + + public function retire(string $id): array + { + $path = $this->bindPath(Endpoint::CHARACTER, ['id' => $id]); + return $this->request('DELETE', $path); + } + + public function stats(string $id): array + { + $path = $this->bindPath(Endpoint::CHARACTER_STATS, ['id' => $id]); + return $this->request('GET', $path); + } + + public function expertise(string $id): array + { + $path = $this->bindPath(Endpoint::CHARACTER_EXPERTISE, ['id' => $id]); + return $this->request('GET', $path); + } + + public function knowledge(string $id): array + { + $path = $this->bindPath(Endpoint::CHARACTER_KNOWLEDGE, ['id' => $id]); + return $this->request('GET', $path); + } + + public function inventory(string $id, ?string $type = null): array + { + $path = $this->bindPath(Endpoint::CHARACTER_INVENTORY, ['id' => $id]); + if ($type !== null) { + $path = $this->withQuery($path, ['type' => $type]); + } + return $this->request('GET', $path); + } + + public function contracts(string $id): array + { + $path = $this->bindPath(Endpoint::CHARACTER_CONTRACTS, ['id' => $id]); + return $this->request('GET', $path); + } + + public function wallet(string $id): array + { + $path = $this->bindPath(Endpoint::CHARACTER_WALLET, ['id' => $id]); + return $this->request('GET', $path); + } + + public function researchJournal(string $id, ?string $knowledgeId = null): array + { + $path = $this->bindPath(Endpoint::CHARACTER_RESEARCH_JOURNAL, ['id' => $id]); + if ($knowledgeId !== null) { + $path .= '/' . $knowledgeId; + } + return $this->request('GET', $path); + } + + public function researchDiscoveries(string $id): array + { + $path = $this->bindPath(Endpoint::CHARACTER_RESEARCH_DISCOVERIES, ['id' => $id]); + return $this->request('GET', $path); + } + + + + public function update(string $id, array $data): array + { + // No generic update endpoint defined; repositories that support patching + // should override this. + throw new \BadMethodCallException('Update not supported on Character repository'); + } + + public function delete(string $id): bool + { + $this->retire($id); + return true; + } +} diff --git a/src/IA/backend/Api/Repositories/Contracts.php b/src/IA/backend/Api/Repositories/Contracts.php new file mode 100644 index 0000000..19ce8b7 --- /dev/null +++ b/src/IA/backend/Api/Repositories/Contracts.php @@ -0,0 +1,108 @@ +discrim])) { + $path = $this->bindPath(Endpoint::CONTRACT, [$this->discrim => $data[$this->discrim]]); + return $this->request('PATCH', $path, $data); + } + + return $this->request('POST', Endpoint::CONTRACTS, $data); + } + + public function fetch(string $id): array + { + $path = $this->bindPath(Endpoint::CONTRACT, ['id' => $id]); + return $this->request('GET', $path); + } + + public function cancel(string $id): array + { + $path = $this->bindPath(Endpoint::CONTRACT, ['id' => $id]); + return $this->request('DELETE', $path); + } + + public function pause(string $id): array + { + $path = $this->bindPath(Endpoint::CONTRACT, ['id' => $id]); + return $this->request('PATCH', $path, ['action' => 'pause']); + } + + public function resume(string $id): array + { + $path = $this->bindPath(Endpoint::CONTRACT, ['id' => $id]); + return $this->request('PATCH', $path, ['action' => 'resume']); + } + + public function contributions(string $id): array + { + $path = $this->bindPath(Endpoint::CONTRACT_CONTRIBUTIONS, ['id' => $id]); + return $this->request('GET', $path); + } + + public function contribute(string $id, string $charId): array + { + $path = $this->bindPath(Endpoint::CONTRACT_CONTRIBUTE, ['id' => $id]); + return $this->request('POST', $path, ['character_id' => $charId]); + } + + public function withdraw(string $id): array + { + $path = $this->bindPath(Endpoint::CONTRACT_WITHDRAW, ['id' => $id]); + return $this->request('DELETE', $path); + } + + public function approve(string $id, string $charId): array + { + $path = $this->bindPath(Endpoint::CONTRACT_APPROVE, ['id' => $id, 'charId' => $charId]); + return $this->request('POST', $path); + } + + public function invite(string $id, string $charId): array + { + $path = $this->bindPath(Endpoint::CONTRACT_INVITE, ['id' => $id, 'charId' => $charId]); + return $this->request('POST', $path); + } + + public function submitTrial(string $id, array $params): array + { + $path = $this->bindPath(Endpoint::CONTRACT_TRIAL, ['id' => $id]); + return $this->request('POST', $path, $params); + } + + public function trials(string $id): array + { + $path = $this->bindPath(Endpoint::CONTRACT_TRIALS, ['id' => $id]); + return $this->request('GET', $path); + } + + + + public function update(string $id, array $data): array + { + // Use pause/resume semantics via PATCH action + if (isset($data['action']) && $data['action'] === 'pause') { + return $this->pause($id); + } + if (isset($data['action']) && $data['action'] === 'resume') { + return $this->resume($id); + } + + throw new \BadMethodCallException('Update not supported for given payload on Contract repository'); + } + + public function delete(string $id): bool + { + $this->cancel($id); + return true; + } +} diff --git a/src/IA/backend/Api/Repositories/Governance.php b/src/IA/backend/Api/Repositories/Governance.php new file mode 100644 index 0000000..51b8333 --- /dev/null +++ b/src/IA/backend/Api/Repositories/Governance.php @@ -0,0 +1,52 @@ +request('GET', Endpoint::GOVERNANCE_OFFICES); + } + + public function elections(): array + { + return $this->request('GET', Endpoint::GOVERNANCE_ELECTIONS); + } + + public function vote(string $electionId, array $data): array + { + $path = $this->bindPath(Endpoint::GOVERNANCE_ELECTION_VOTE, ['id' => $electionId]); + return $this->request('POST', $path, $data); + } + + public function policies(): array + { + return $this->request('GET', Endpoint::GOVERNANCE_POLICIES); + } + + public function propose(array $data): array + { + return $this->request('POST', Endpoint::GOVERNANCE_PROPOSE_POLICY, $data); + } + + public function save(array $data): array + { + return $this->propose($data); + } + + public function update(string $id, array $data): array + { + throw new \BadMethodCallException('Update not supported on Governance repository'); + } + + public function delete(string $id): bool + { + throw new \BadMethodCallException('Delete not supported on Governance repository'); + } +} diff --git a/src/IA/backend/Api/Repositories/Knowledge.php b/src/IA/backend/Api/Repositories/Knowledge.php new file mode 100644 index 0000000..5af625b --- /dev/null +++ b/src/IA/backend/Api/Repositories/Knowledge.php @@ -0,0 +1,44 @@ +request('GET', Endpoint::KNOWLEDGE); + } + + public function fetch(string $id): array + { + $path = $this->bindPath(Endpoint::KNOWLEDGE_ITEM, ['id' => $id]); + return $this->request('GET', $path); + } + + public function researchPaths(string $id, string $charId): array + { + $path = $this->bindPath(Endpoint::KNOWLEDGE_RESEARCH_PATHS, ['id' => $id]); + $path = $this->withQuery($path, ['character_id' => $charId]); + return $this->request('GET', $path); + } + + public function save(array $data): array + { + throw new \BadMethodCallException('Save not supported on Knowledge repository'); + } + + public function update(string $id, array $data): array + { + throw new \BadMethodCallException('Update not supported on Knowledge repository'); + } + + public function delete(string $id): bool + { + throw new \BadMethodCallException('Delete not supported on Knowledge repository'); + } +} diff --git a/src/IA/backend/Api/Repositories/Market.php b/src/IA/backend/Api/Repositories/Market.php new file mode 100644 index 0000000..8399c4b --- /dev/null +++ b/src/IA/backend/Api/Repositories/Market.php @@ -0,0 +1,46 @@ +request('GET', Endpoint::MARKET_LISTINGS); + } + + public function listItem(array $data): array + { + return $this->request('POST', Endpoint::MARKET_LIST, $data); + } + + public function buy(array $data): array + { + return $this->request('POST', Endpoint::MARKET_BUY, $data); + } + + public function history(): array + { + return $this->request('GET', Endpoint::MARKET_HISTORY); + } + + public function save(array $data): array + { + return $this->listItem($data); + } + + public function update(string $id, array $data): array + { + throw new \BadMethodCallException('Update not supported on Market repository'); + } + + public function delete(string $id): bool + { + throw new \BadMethodCallException('Delete not supported on Market repository'); + } +} diff --git a/src/IA/backend/Api/Repositories/World.php b/src/IA/backend/Api/Repositories/World.php new file mode 100644 index 0000000..facffac --- /dev/null +++ b/src/IA/backend/Api/Repositories/World.php @@ -0,0 +1,76 @@ +request('GET', Endpoint::WORLD_TIME); + } + + public function zones(): array + { + return $this->request('GET', Endpoint::WORLD_ZONES); + } + + public function zone(string $id): array + { + $path = $this->bindPath(Endpoint::WORLD_ZONE, ['id' => $id]); + return $this->request('GET', $path); + } + + public function buildings(string $zoneId): array + { + $path = $this->bindPath(Endpoint::WORLD_ZONE_BUILDINGS, ['id' => $zoneId]); + return $this->request('GET', $path); + } + + public function stockpile(string $zoneId): array + { + $path = $this->bindPath(Endpoint::WORLD_ZONE_STOCKPILE, ['id' => $zoneId]); + return $this->request('GET', $path); + } + + public function mana(string $zoneId): array + { + $path = $this->bindPath(Endpoint::WORLD_ZONE_MANA, ['id' => $zoneId]); + return $this->request('GET', $path); + } + + public function treasury(string $zoneId): array + { + $path = $this->bindPath(Endpoint::WORLD_ZONE_TREASURY, ['id' => $zoneId]); + return $this->request('GET', $path); + } + + public function events(): array + { + return $this->request('GET', Endpoint::WORLD_EVENTS); + } + + public function nextRaid(): array + { + return $this->request('GET', Endpoint::WORLD_RAIDS_NEXT); + } + + public function save(array $data): array + { + throw new \BadMethodCallException('Save not supported on World repository'); + } + + public function update(string $id, array $data): array + { + throw new \BadMethodCallException('Update not supported on World repository'); + } + + public function delete(string $id): bool + { + throw new \BadMethodCallException('Delete not supported on World repository'); + } +} diff --git a/src/IA/backend/Api/RepositoryInterface.php b/src/IA/backend/Api/RepositoryInterface.php new file mode 100644 index 0000000..6795d46 --- /dev/null +++ b/src/IA/backend/Api/RepositoryInterface.php @@ -0,0 +1,37 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Auth; + +use Psr\Http\Message\ServerRequestInterface; + +class Handler +{ + public function login(ServerRequestInterface $request) + { + return ['token' => 'DEV-TOKEN']; + } + + public function register(ServerRequestInterface $request) + { + return ['status' => 'ok']; + } +} diff --git a/src/IA/backend/Auth/Middleware.php b/src/IA/backend/Auth/Middleware.php new file mode 100644 index 0000000..9be67dd --- /dev/null +++ b/src/IA/backend/Auth/Middleware.php @@ -0,0 +1,40 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Auth; + +use Psr\Http\Message\ServerRequestInterface; + +class Middleware +{ + protected TokenService $tokens; + + public function __construct(TokenService $tokens) + { + $this->tokens = $tokens; + } + + public function handle(ServerRequestInterface $request, callable $next) + { + $auth = $request->getHeaderLine('Authorization'); + if ($auth && preg_match('/^Bearer\s+(.*)$/', $auth, $m)) { + $token = $m[1]; + $payload = $this->tokens->verifyToken($token); + if ($payload) { + return $next($request->withAttribute('auth', $payload)); + } + } + + return [401, ['Content-Type' => 'application/json'], json_encode(['error' => 'Unauthorized'])]; + } +} diff --git a/src/IA/backend/Auth/TokenService.php b/src/IA/backend/Auth/TokenService.php new file mode 100644 index 0000000..e3b538e --- /dev/null +++ b/src/IA/backend/Auth/TokenService.php @@ -0,0 +1,66 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Auth; + +use Firebase\JWT\JWT; +use Firebase\JWT\Key; +use Firebase\JWT\ExpiredException; +use Firebase\JWT\SignatureInvalidException; + +class TokenService +{ + protected string $secret; + protected string $algo; + + public function __construct(string $secret, string $algo = 'HS256') + { + $this->secret = $secret; + $this->algo = $algo; + } + + /** + * Create a signed JWT. + * + * @param array $payload Claims to include in the token (will be merged with exp/iat) + * @param int|null $ttl Seconds until expiry. Null = no exp claim. + */ + public function createToken(array $payload, ?int $ttl = 3600): string + { + $now = time(); + $claims = $payload; + $claims['iat'] = $now; + + if ($ttl !== null) { + $claims['exp'] = $now + $ttl; + } + + return JWT::encode($claims, $this->secret, $this->algo); + } + + /** + * Verify and decode a JWT. Returns claims array on success or null on failure. + */ + public function verifyToken(string $token): ?array + { + try { + $decoded = JWT::decode($token, new Key($this->secret, $this->algo)); + + return json_decode(json_encode($decoded), true); + } catch (ExpiredException|SignatureInvalidException $e) { + return null; + } catch (\Throwable $e) { + return null; + } + } +} diff --git a/src/IA/backend/Character/Handler.php b/src/IA/backend/Character/Handler.php new file mode 100644 index 0000000..d231db9 --- /dev/null +++ b/src/IA/backend/Character/Handler.php @@ -0,0 +1,34 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Character; + +use Psr\Http\Message\ServerRequestInterface; +use React\Promise\PromiseInterface; + +class Handler +{ + protected Service $service; + + public function __construct(Service $service) + { + $this->service = $service; + } + + public function getStats(ServerRequestInterface $request): PromiseInterface + { + $id = $request->getAttribute('character_id'); + + return $this->service->getStats($id); + } +} diff --git a/src/IA/backend/Character/Repository.php b/src/IA/backend/Character/Repository.php new file mode 100644 index 0000000..a9620ff --- /dev/null +++ b/src/IA/backend/Character/Repository.php @@ -0,0 +1,26 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Character; + +use React\Promise\PromiseInterface; +use function React\Promise\resolve; + +class Repository +{ + public function find(string $id): PromiseInterface + { + // Placeholder: resolve null if not found. Replace with async DB call. + return resolve(null); + } +} diff --git a/src/IA/backend/Character/Service.php b/src/IA/backend/Character/Service.php new file mode 100644 index 0000000..27dc8f7 --- /dev/null +++ b/src/IA/backend/Character/Service.php @@ -0,0 +1,35 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Character; + +use React\Promise\PromiseInterface; +use function React\Promise\resolve; + +class Service +{ + protected Repository $repo; + + public function __construct(Repository $repo) + { + $this->repo = $repo; + } + + public function getStats(string $id): PromiseInterface + { + // placeholder + $res = ['id' => $id, 'stats' => []]; + + return resolve($res); + } +} diff --git a/src/IA/backend/Contract/Contract.php b/src/IA/backend/Contract/Contract.php new file mode 100644 index 0000000..7d492cd --- /dev/null +++ b/src/IA/backend/Contract/Contract.php @@ -0,0 +1,63 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Contract; + +final class Contract +{ + public const STATUS_PENDING = 'PENDING'; + public const STATUS_ACTIVE = 'ACTIVE'; + public const STATUS_PAUSED = 'PAUSED'; + public const STATUS_RESOLVED = 'RESOLVED'; + + protected string $id; + protected string $status = self::STATUS_PENDING; + protected ?string $pauseReason = null; + + public function __construct(string $id) + { + $this->id = $id; + } + + public function activate(): void + { + if ($this->status === self::STATUS_PENDING || $this->status === self::STATUS_PAUSED) { + $this->status = self::STATUS_ACTIVE; + $this->pauseReason = null; + } + } + + public function pause(string $reason): void + { + if ($this->status === self::STATUS_ACTIVE) { + $this->status = self::STATUS_PAUSED; + $this->pauseReason = $reason; + } + } + + public function resolve(): void + { + $this->status = self::STATUS_RESOLVED; + $this->pauseReason = null; + } + + public function getStatus(): string + { + return $this->status; + } + + public function getPauseReason(): ?string + { + return $this->pauseReason; + } +} diff --git a/src/IA/backend/Contract/Handler.php b/src/IA/backend/Contract/Handler.php new file mode 100644 index 0000000..9878589 --- /dev/null +++ b/src/IA/backend/Contract/Handler.php @@ -0,0 +1,32 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Contract; + +use Psr\Http\Message\ServerRequestInterface; +use React\Promise\PromiseInterface; + +class Handler +{ + protected Service $service; + + public function __construct(Service $service) + { + $this->service = $service; + } + + public function create(ServerRequestInterface $request): PromiseInterface + { + return $this->service->create(json_decode((string) $request->getBody(), true)); + } +} diff --git a/src/IA/backend/Contract/PartRepository.php b/src/IA/backend/Contract/PartRepository.php new file mode 100644 index 0000000..8129280 --- /dev/null +++ b/src/IA/backend/Contract/PartRepository.php @@ -0,0 +1,26 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Contract\Contract; + +use BackendPhp\Support\PartFactory; +use BackendPhp\Parts\Contract; + +final class PartRepository +{ + public function hydrateFromRow(array $row): Contract + { + // Convert DB row keys (snake_case) to attributes as-is + return PartFactory::create('contract', $row); + } +} diff --git a/src/IA/backend/Contract/Repository.php b/src/IA/backend/Contract/Repository.php new file mode 100644 index 0000000..5259cd8 --- /dev/null +++ b/src/IA/backend/Contract/Repository.php @@ -0,0 +1,26 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Contract; + +use React\Promise\PromiseInterface; +use function React\Promise\resolve; + +class Repository +{ + public function create(array $payload): PromiseInterface + { + // In a real async implementation this would perform an async DB insert. + return resolve($payload + ['id' => bin2hex(random_bytes(8))]); + } +} diff --git a/src/IA/backend/Contract/Resolver.php b/src/IA/backend/Contract/Resolver.php new file mode 100644 index 0000000..7af53ea --- /dev/null +++ b/src/IA/backend/Contract/Resolver.php @@ -0,0 +1,22 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Contract; + +class Resolver +{ + public function resolve(array $contract): array + { + return ['resolved' => true]; + } +} diff --git a/src/IA/backend/Contract/Service.php b/src/IA/backend/Contract/Service.php new file mode 100644 index 0000000..b7771ab --- /dev/null +++ b/src/IA/backend/Contract/Service.php @@ -0,0 +1,38 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Contract; + +use React\Promise\PromiseInterface; +use function React\Promise\resolve; + +class Service +{ + protected Repository $repo; + + public function __construct(Repository $repo) + { + $this->repo = $repo; + } + + /** + * Create a contract — returns a Promise resolving to the created data. + */ + public function create(array $data): PromiseInterface + { + // Validate and reserve resources using repo (sync here) + $result = ['status' => 'created', 'data' => $data]; + + return resolve($result); + } +} diff --git a/src/IA/backend/Contract/Types.php b/src/IA/backend/Contract/Types.php new file mode 100644 index 0000000..092249e --- /dev/null +++ b/src/IA/backend/Contract/Types.php @@ -0,0 +1,21 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Contract; + +final class Types +{ + public const KNOWLEDGE_COMPLETION = 'KNOWLEDGE_COMPLETION'; + public const RESEARCH = 'RESEARCH'; + public const CRAFTING = 'CRAFTING'; +} diff --git a/src/IA/backend/Database/ConnectionPool.php b/src/IA/backend/Database/ConnectionPool.php new file mode 100644 index 0000000..390c206 --- /dev/null +++ b/src/IA/backend/Database/ConnectionPool.php @@ -0,0 +1,55 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Database; + +use BackendPhp\Support\Config; + +class ConnectionPool +{ + protected array $pool = []; + protected Config $config; + + public function __construct(Config $config) + { + $this->config = $config; + } + + public function getConnection(): \PDO + { + // Simple single-connection pool for sync operations + if (! isset($this->pool['default'])) { + $dbCfg = $this->config->get('db', []); + if (is_array($dbCfg)) { + $dsn = $dbCfg['dsn'] ?? ''; + $user = $dbCfg['user'] ?? null; + $pass = $dbCfg['pass'] ?? null; + } else { + $dsn = (string) $dbCfg; + $user = null; + $pass = null; + } + $opts = [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]; + // Try DSN-only construction first (works for sqlite and many drivers), + // fall back to DSN+credentials+options if that fails. + try { + $this->pool['default'] = new \PDO($dsn); + $this->pool['default']->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + } catch (\PDOException $e) { + $this->pool['default'] = new \PDO($dsn, $user, $pass, $opts); + } + } + + return $this->pool['default']; + } +} diff --git a/src/IA/backend/Database/Postgres.php b/src/IA/backend/Database/Postgres.php new file mode 100644 index 0000000..332519c --- /dev/null +++ b/src/IA/backend/Database/Postgres.php @@ -0,0 +1,22 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Database; + +class Postgres +{ + public static function dsn(string $host, int $port, string $db): string + { + return sprintf('pgsql:host=%s;port=%d;dbname=%s', $host, $port, $db); + } +} diff --git a/src/IA/backend/Domain/EffortCalculator.php b/src/IA/backend/Domain/EffortCalculator.php new file mode 100644 index 0000000..5a69a57 --- /dev/null +++ b/src/IA/backend/Domain/EffortCalculator.php @@ -0,0 +1,35 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Domain; + +final class EffortCalculator +{ + public static function calculateEfficiency(float $relevantStat, float $referenceValue): float + { + if ($referenceValue <= 0) { + throw new \InvalidArgumentException('referenceValue must be > 0'); + } + + return $relevantStat / $referenceValue; + } + + public static function generateEffort(float $staminaDrained, float $efficiency): float + { + if ($staminaDrained < 0 || $efficiency < 0) { + throw new \InvalidArgumentException('Values must be non-negative'); + } + + return $staminaDrained * $efficiency; + } +} diff --git a/src/IA/backend/Domain/ExpertiseCalculator.php b/src/IA/backend/Domain/ExpertiseCalculator.php new file mode 100644 index 0000000..8734b1d --- /dev/null +++ b/src/IA/backend/Domain/ExpertiseCalculator.php @@ -0,0 +1,40 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Domain; + +final class ExpertiseCalculator +{ + /** + * contributors: array of ['effort' => float, 'expertise' => float]. + */ + public static function weightedExpertise(array $contributors): float + { + $totalEffort = 0.0; + foreach ($contributors as $c) { + $totalEffort += $c['effort']; + } + + if ($totalEffort <= 0) { + return 0.0; + } + + $weighted = 0.0; + foreach ($contributors as $c) { + $weight = $c['effort'] / $totalEffort; + $weighted += $weight * $c['expertise']; + } + + return $weighted; + } +} diff --git a/src/IA/backend/Domain/WorkUnit.php b/src/IA/backend/Domain/WorkUnit.php new file mode 100644 index 0000000..4ad1cb1 --- /dev/null +++ b/src/IA/backend/Domain/WorkUnit.php @@ -0,0 +1,26 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Domain; + +final class WorkUnit +{ + public static function fromActiveSeconds(int $seconds): float + { + if ($seconds < 0) { + throw new \InvalidArgumentException('seconds must be non-negative'); + } + + return $seconds / 60.0; + } +} diff --git a/src/IA/backend/Migrations/Runner.php b/src/IA/backend/Migrations/Runner.php new file mode 100644 index 0000000..a77b4c4 --- /dev/null +++ b/src/IA/backend/Migrations/Runner.php @@ -0,0 +1,63 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Migrations; + +use BackendPhp\Database\ConnectionPool; + +class Runner +{ + protected ConnectionPool $pool; + protected string $migrationsPath; + + public function __construct(ConnectionPool $pool, string $migrationsPath) + { + $this->pool = $pool; + $this->migrationsPath = $migrationsPath; + } + + public function runPending(): void + { + $pdo = $this->pool->getConnection(); + $pdo->beginTransaction(); + try { + // ensure migrations table (use portable SQL depending on driver) + $driver = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + if ($driver === 'sqlite') { + $pdo->exec("CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, ran_at TEXT DEFAULT (datetime('now')))"); + } else { + $pdo->exec('CREATE TABLE IF NOT EXISTS migrations (id TEXT PRIMARY KEY, ran_at TIMESTAMPTZ DEFAULT now())'); + } + + $files = glob(rtrim($this->migrationsPath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'*.sql'); + foreach ($files as $f) { + $id = basename($f); + $stmt = $pdo->prepare('SELECT 1 FROM migrations WHERE id = :id'); + $stmt->execute(['id' => $id]); + if ($stmt->fetchColumn()) { + continue; + } + + $sql = file_get_contents($f); + $pdo->exec($sql); + $ins = $pdo->prepare('INSERT INTO migrations (id) VALUES (:id)'); + $ins->execute(['id' => $id]); + } + + $pdo->commit(); + } catch (\Throwable $e) { + $pdo->rollBack(); + throw $e; + } + } +} diff --git a/src/IA/backend/Parts/Character.php b/src/IA/backend/Parts/Character.php new file mode 100644 index 0000000..e79430f --- /dev/null +++ b/src/IA/backend/Parts/Character.php @@ -0,0 +1,113 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Parts; + +use Discord\Discord; +use Discord\Parts\Part as DiscordPart; + +final class Character extends DiscordPart implements \JsonSerializable +{ + // maintain compatibility with previous local Part API + protected $fillable = ['id', 'created_at', 'updated_at']; + protected $original = []; + + public function __construct($discordOrAttributes = [], array $attributes = [], bool $created = false) + { + if ($discordOrAttributes instanceof Discord) { + parent::__construct($discordOrAttributes, $attributes, $created); + + return; + } + + $attrs = is_array($discordOrAttributes) ? $discordOrAttributes : []; + if (! empty($attributes)) { + $attrs = $attributes; + } + + $this->fill($attrs); + $this->syncOriginal(); + $this->created = false; + } + + public function getOriginal(): array + { + return $this->original; + } + + public function isDirty(): bool + { + return ($this->attributes ?? []) !== $this->original; + } + + public function syncOriginal(): void + { + $this->original = $this->attributes ?? []; + } + + public function jsonSerialize(): array + { + $out = []; + foreach ($this->getRawAttributes() as $k => $v) { + if ($v instanceof DiscordPart) { + $out[$k] = $v->jsonSerialize(); + } elseif (is_array($v)) { + $out[$k] = array_map(fn ($x) => $x instanceof DiscordPart ? $x->jsonSerialize() : $x, $v); + } else { + $out[$k] = $v; + } + } + + return $out; + } + + public function __set(string $name, $value): void + { + $this->attributes[$name] = $value; + } + + public function __isset(string $name): bool + { + return isset($this->attributes[$name]); + } + + protected function setCreatedAtAttribute($value): void + { + if (is_string($value)) { + try { + $this->attributes['created_at'] = new \Carbon\Carbon($value); + + return; + } catch (\Throwable $e) { + // fallthrough + } + } + + $this->attributes['created_at'] = $value; + } + + protected function setUpdatedAtAttribute($value): void + { + if (is_string($value)) { + try { + $this->attributes['updated_at'] = new \Carbon\Carbon($value); + + return; + } catch (\Throwable $e) { + // fallthrough + } + } + + $this->attributes['updated_at'] = $value; + } +} diff --git a/src/IA/backend/Parts/Contract.php b/src/IA/backend/Parts/Contract.php new file mode 100644 index 0000000..5910900 --- /dev/null +++ b/src/IA/backend/Parts/Contract.php @@ -0,0 +1,123 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Parts; + +use Discord\Parts\Part as DiscordPart; +use BackendPhp\Support\PartFactory; + +final class Contract extends DiscordPart implements \JsonSerializable +{ + // maintain compatibility with previous local Part API + protected $fillable = ['id', 'owner', 'started_at', 'resolved_at']; + protected $original = []; + + public function __construct($discordOrAttributes = [], array $attributes = [], bool $created = false) + { + if ($discordOrAttributes instanceof \Discord\Discord) { + parent::__construct($discordOrAttributes, $attributes, $created); + + return; + } + + $attrs = is_array($discordOrAttributes) ? $discordOrAttributes : []; + if (! empty($attributes)) { + $attrs = $attributes; + } + + $this->fill($attrs); + $this->syncOriginal(); + $this->created = false; + } + + public function getOriginal(): array + { + return $this->original; + } + + public function isDirty(): bool + { + return ($this->attributes ?? []) !== $this->original; + } + + public function syncOriginal(): void + { + $this->original = $this->attributes ?? []; + } + + public function jsonSerialize(): array + { + $out = []; + foreach ($this->getRawAttributes() as $k => $v) { + if ($v instanceof DiscordPart) { + $out[$k] = $v->jsonSerialize(); + } elseif (is_array($v)) { + $out[$k] = array_map(fn ($x) => $x instanceof DiscordPart ? $x->jsonSerialize() : $x, $v); + } else { + $out[$k] = $v; + } + } + + return $out; + } + + public function __set(string $name, $value): void + { + $this->attributes[$name] = $value; + } + + public function __isset(string $name): bool + { + return isset($this->attributes[$name]); + } + // keep fillable behaviour inherited from Discord\Parts\Part + + protected function setStartedAtAttribute($value): void + { + if (is_string($value)) { + try { + $this->attributes['started_at'] = new \Carbon\Carbon($value); + + return; + } catch (\Throwable $e) { + // fallthrough + } + } + + $this->attributes['started_at'] = $value; + } + + protected function setResolvedAtAttribute($value): void + { + if (is_string($value)) { + try { + $this->attributes['resolved_at'] = new \Carbon\Carbon($value); + + return; + } catch (\Throwable $e) { + // fallthrough + } + } + + $this->attributes['resolved_at'] = $value; + } + + protected function setOwnerAttribute($value): void + { + if (is_array($value)) { + $this->attributes['owner'] = PartFactory::create('character', $value); + } else { + $this->attributes['owner'] = $value; + } + } +} diff --git a/src/IA/backend/Research/Handler.php b/src/IA/backend/Research/Handler.php new file mode 100644 index 0000000..6019abe --- /dev/null +++ b/src/IA/backend/Research/Handler.php @@ -0,0 +1,32 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Research; + +use Psr\Http\Message\ServerRequestInterface; +use React\Promise\PromiseInterface; + +class Handler +{ + protected Service $service; + + public function __construct(Service $service) + { + $this->service = $service; + } + + public function trial(ServerRequestInterface $request): PromiseInterface + { + return $this->service->trial(json_decode((string) $request->getBody(), true)); + } +} diff --git a/src/IA/backend/Research/Repository.php b/src/IA/backend/Research/Repository.php new file mode 100644 index 0000000..d1fd748 --- /dev/null +++ b/src/IA/backend/Research/Repository.php @@ -0,0 +1,22 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Research; + +class Repository +{ + public function saveTrialResult(array $result): void + { + // Persist trial result + } +} diff --git a/src/IA/backend/Research/Service.php b/src/IA/backend/Research/Service.php new file mode 100644 index 0000000..16ed959 --- /dev/null +++ b/src/IA/backend/Research/Service.php @@ -0,0 +1,35 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Research; + +use React\Promise\PromiseInterface; +use function React\Promise\resolve; + +class Service +{ + protected Repository $repo; + + public function __construct(Repository $repo) + { + $this->repo = $repo; + } + + public function trial(array $params): PromiseInterface + { + // Run experimental trial logic, return observation + $result = ['result' => 'no_data', 'params' => $params]; + + return resolve($result); + } +} diff --git a/src/IA/backend/Research/TrialService.php b/src/IA/backend/Research/TrialService.php new file mode 100644 index 0000000..21f8fd1 --- /dev/null +++ b/src/IA/backend/Research/TrialService.php @@ -0,0 +1,22 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Research; + +class TrialService +{ + public function run(array $params): array + { + return ['dropped' => []]; + } +} diff --git a/src/IA/backend/Reservation/Service.php b/src/IA/backend/Reservation/Service.php new file mode 100644 index 0000000..1861a51 --- /dev/null +++ b/src/IA/backend/Reservation/Service.php @@ -0,0 +1,57 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Reservation; + +final class Service +{ + /** + * inventory: [itemType => quantity]. + */ + protected array $inventory = []; + + public function __construct(array $initialInventory = []) + { + $this->inventory = $initialInventory; + } + + public function available(string $itemType): int + { + return $this->inventory[$itemType] ?? 0; + } + + /** + * Attempt to reserve quantity; atomic: either all reserved or none. + */ + public function reserve(string $itemType, int $quantity): bool + { + if ($quantity <= 0) { + return false; + } + + $available = $this->available($itemType); + if ($available < $quantity) { + return false; + } + + // reserve (consume) immediately for this simple model + $this->inventory[$itemType] = $available - $quantity; + + return true; + } + + public function release(string $itemType, int $quantity): void + { + $this->inventory[$itemType] = $this->available($itemType) + $quantity; + } +} diff --git a/src/IA/backend/Server/Http.php b/src/IA/backend/Server/Http.php new file mode 100644 index 0000000..2a1606a --- /dev/null +++ b/src/IA/backend/Server/Http.php @@ -0,0 +1,51 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Server; + +use React\EventLoop\LoopInterface; +use React\Socket\TcpServer; +use React\Http\HttpServer as ReactHttpServer; +use Psr\Http\Message\ServerRequestInterface; +use React\EventLoop\Loop; +use Sharkk\Router\Router as SharkkRouter; + +class Http +{ + protected LoopInterface $loop; + protected ReactHttpServer $server; + protected TcpServer $socket; + /** @var mixed Router implementation (sharkk/router or local) */ + protected $router; + + public function __construct(?LoopInterface $loop = null, $router = null) + { + $this->loop = $loop ?? Loop::get(); + + if ($router !== null) { + $this->router = $router; + } else { + $this->router = new SharkkRouter(); + } + + $this->server = new ReactHttpServer(function (ServerRequestInterface $request) { + return $this->router->dispatch($request); + }); + } + + public function listen(string $host, int $port): void + { + $this->socket = new TcpServer("{$host}:{$port}", $this->loop); + $this->server->listen($this->socket); + } +} diff --git a/src/IA/backend/Server/WebSocket.php b/src/IA/backend/Server/WebSocket.php new file mode 100644 index 0000000..076a216 --- /dev/null +++ b/src/IA/backend/Server/WebSocket.php @@ -0,0 +1,33 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Server; + +use React\EventLoop\Loop; +use React\EventLoop\LoopInterface; + +class WebSocket +{ + protected LoopInterface $loop; + + public function __construct(?LoopInterface $loop = null) + { + $this->loop = $loop ?? Loop::get(); + } + + public function listen(string $host, int $port): void + { + // For a minimal skeleton we don't spin a full Ratchet server here. + // Implementation note: use Ratchet or ReactPHP WebSocket libraries. + } +} diff --git a/src/IA/backend/Support/Config.php b/src/IA/backend/Support/Config.php new file mode 100644 index 0000000..25fe879 --- /dev/null +++ b/src/IA/backend/Support/Config.php @@ -0,0 +1,45 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Support; + +final class Config +{ + protected array $data = []; + + public function __construct(array $data = []) + { + $this->data = $data; + } + + public static function fromFile(string $path): self + { + if (! file_exists($path)) { + return new self([]); + } + $json = file_get_contents($path); + $arr = json_decode($json, true) ?: []; + + return new self($arr); + } + + public function get(string $key, $default = null) + { + return $this->data[$key] ?? $default; + } + + public function all(): array + { + return $this->data; + } +} diff --git a/src/IA/backend/Support/Container.php b/src/IA/backend/Support/Container.php new file mode 100644 index 0000000..37b1912 --- /dev/null +++ b/src/IA/backend/Support/Container.php @@ -0,0 +1,46 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Support; + +class Container +{ + protected array $items = []; + + public function set(string $id, $value): void + { + $this->items[$id] = $value; + } + + public function get(string $id) + { + if (! array_key_exists($id, $this->items)) { + return null; + } + + $val = $this->items[$id]; + if (is_callable($val)) { + // lazy factory: replace with resolved value + $this->items[$id] = $val($this); + + return $this->items[$id]; + } + + return $val; + } + + public function has(string $id): bool + { + return array_key_exists($id, $this->items); + } +} diff --git a/src/IA/backend/Support/DiscordServiceProvider.php b/src/IA/backend/Support/DiscordServiceProvider.php new file mode 100644 index 0000000..af8b447 --- /dev/null +++ b/src/IA/backend/Support/DiscordServiceProvider.php @@ -0,0 +1,59 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Support; + +use Discord\Discord; +use Discord\Factory\Factory as DiscordFactory; + +final class DiscordServiceProvider extends ServiceProvider +{ + public function register(Container $container): void + { + // discord client + $container->set('discord', function ($c) { + $cfgAll = $c->get('config') ?? []; + $cfg = is_array($cfgAll) ? ($cfgAll['discord'] ?? []) : []; + $options = [ + 'token' => $cfg['token'] ?? '', + 'loop' => $c->get('loop'), + 'logger' => $c->get('logger'), + ]; + + return new Discord($options); + }); + + // discord factory + $container->set('discord.factory', function ($c) { + $discord = $c->get('discord'); + if ($discord === null) { + return null; + } + + return new DiscordFactory($discord); + }); + + // register PartFactory as a service (injectable) + $container->set('part.factory', function ($c) { + return new \BackendPhp\Support\PartFactory($c->get('discord.factory')); + }); + + // Eagerly bind our PartFactory to use the Discord factory so parts created + // through PartFactory will use the Discord client where appropriate. + $factory = $container->get('discord.factory'); + if ($factory !== null) { + // preserve backwards-compatible static API for now + \BackendPhp\Support\PartFactory::setDiscordFactory($factory); + } + } +} diff --git a/src/IA/backend/Support/MonologFactory.php b/src/IA/backend/Support/MonologFactory.php new file mode 100644 index 0000000..c2d30d3 --- /dev/null +++ b/src/IA/backend/Support/MonologFactory.php @@ -0,0 +1,31 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Support; + +use Monolog\Logger as MonologLogger; +use Monolog\Handler\StreamHandler; +use Monolog\Formatter\LineFormatter; + +class MonologFactory +{ + public static function create(string $name = 'in-absentia'): MonologLogger + { + $logger = new MonologLogger($name); + $handler = new StreamHandler('php://stderr', MonologLogger::DEBUG); + $handler->setFormatter(new LineFormatter(null, null, true, true)); + $logger->pushHandler($handler); + + return $logger; + } +} diff --git a/src/IA/backend/Support/PartFactory.php b/src/IA/backend/Support/PartFactory.php new file mode 100644 index 0000000..527f115 --- /dev/null +++ b/src/IA/backend/Support/PartFactory.php @@ -0,0 +1,120 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Support; + +use BackendPhp\Parts\Contract; +use BackendPhp\Parts\Character; + +final class PartFactory +{ + protected $discordFactory = null; + + /** @var null|self Global compatibility instance */ + protected static ?self $globalInstance = null; + + public function __construct($discordFactory = null) + { + $this->discordFactory = $discordFactory; + } + + public function getDiscordFactory() + { + return $this->discordFactory; + } + + /** + * Compatibility shim: accept the previous static setter which provided + * the Discord factory. This sets a global PartFactory instance used by + * existing static call sites. + */ + public static function setDiscordFactory($factory): void + { + self::$globalInstance = new self($factory); + } + + /** + * Static facade kept for backwards compatibility. Delegates to the + * injectable instance (global if configured) so existing call sites + * continue to work. + */ + public static function create(string $type, array $attributes = []): object + { + $instance = self::$globalInstance ?? new self(null); + + return $instance->createInstance($type, $attributes); + } + + /** + * Instance-based creation method. New code should call this on an + * injected `PartFactory` instance. + */ + public function createInstance(string $type, array $attributes = []): object + { + $map = [ + 'contract' => Contract::class, + 'character' => Character::class, + ]; + + $class = $map[$type] ?? $type; + if (! class_exists($class)) { + throw new \InvalidArgumentException("Unknown part class: {$class}"); + } + + // If a Discord factory is registered and the class is a Discord Part, + // delegate creation to the Discord factory so it gets the Discord client. + if ($this->discordFactory !== null) { + try { + if (is_subclass_of($class, \Discord\Parts\Part::class) || str_starts_with($class, 'Discord\\Parts\\')) { + return $this->discordFactory->part($class, $attributes, false); + } + } catch (\Throwable $e) { + // Fall back to direct instantiation on error + } + } + + // If it's a Discord Part class but we don't have a Discord factory registered, + // create a minimal Discord stub instance to satisfy the constructor typehint + // so tests and non-discord environments can still instantiate parts. + if (is_subclass_of($class, \Discord\Parts\Part::class) || str_starts_with($class, 'Discord\\Parts\\')) { + $ref = new \ReflectionClass($class); + $instance = $ref->newInstanceWithoutConstructor(); + + if (method_exists($instance, 'fill')) { + $instance->fill($attributes); + } else { + if ($ref->hasProperty('attributes')) { + $propName = 'attributes'; + $setter = function ($val) use ($propName) { + $this->$propName = $val; + }; + $setter = \Closure::bind($setter, $instance, $class); + $setter($attributes); + } + } + + if ($ref->hasProperty('created')) { + $propName = 'created'; + $setter = function ($val) use ($propName) { + $this->$propName = $val; + }; + $setter = \Closure::bind($setter, $instance, $class); + $setter(false); + } + + return $instance; + } + + return new $class($attributes); + } +} diff --git a/src/IA/backend/Support/ServiceProvider.php b/src/IA/backend/Support/ServiceProvider.php new file mode 100644 index 0000000..2c30ff3 --- /dev/null +++ b/src/IA/backend/Support/ServiceProvider.php @@ -0,0 +1,19 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Support; + +abstract class ServiceProvider +{ + abstract public function register(Container $container): void; +} diff --git a/src/IA/backend/Utils/Helpers.php b/src/IA/backend/Utils/Helpers.php new file mode 100644 index 0000000..49c45b2 --- /dev/null +++ b/src/IA/backend/Utils/Helpers.php @@ -0,0 +1,24 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\Utils; + +class Helpers +{ + public static function env(string $key, $default = null) + { + $val = getenv($key); + + return $val === false ? $default : $val; + } +} diff --git a/src/IA/backend/World/Ticker.php b/src/IA/backend/World/Ticker.php new file mode 100644 index 0000000..79a69e9 --- /dev/null +++ b/src/IA/backend/World/Ticker.php @@ -0,0 +1,40 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace BackendPhp\World; + +use React\EventLoop\Loop; +use React\EventLoop\LoopInterface; + +class Ticker +{ + protected LoopInterface $loop; + + public function __construct(?LoopInterface $loop = null) + { + $this->loop = $loop ?? Loop::get(); + } + + public function start(): void + { + // fast tick + $this->loop->addPeriodicTimer(1.0, function () { + // process stamina regen, active contracts, etc. + }); + + // slow tick + $this->loop->addPeriodicTimer(60.0, function () { + // economic aggregates, decay, scheduled events + }); + } +} diff --git a/src/IA/backend/bin/server.php b/src/IA/backend/bin/server.php new file mode 100644 index 0000000..90b09ea --- /dev/null +++ b/src/IA/backend/bin/server.php @@ -0,0 +1,61 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +require __DIR__.'/../../../vendor/autoload.php'; + +use BackendPhp\Server\Http; +use BackendPhp\Server\WebSocket; +use BackendPhp\Support\Container; +use BackendPhp\Support\DiscordServiceProvider; +use BackendPhp\Support\MonologFactory; +use React\EventLoop\Loop; + +$loop = Loop::get(); + +// Create a simple container and register basic services +$container = new Container(); +$container->set('loop', fn ($c) => $loop); +$container->set('logger', fn ($c) => MonologFactory::create('in-absentia')); +$container->set('config', fn ($c) => [ + 'discord' => [ + 'token' => getenv('DISCORD_TOKEN') ?: '', + ], +]); + +// Register Discord services (factory + client) and wire PartFactory +(new DiscordServiceProvider())->register($container); + +// Ensure container exposes an HTTP service and a generic `factory` alias +$container->set('http', function ($c) { return new Http($c->get('loop')); }); +$container->set('factory', function ($c) { return $c->get('part.factory'); }); + +// Register API repositories in the container (factory bindings) +$container->set('repositories.auth', function ($c) { return new \BackendPhp\Api\Repositories\Auth($c); }); +$container->set('repositories.account', function ($c) { return new \BackendPhp\Api\Repositories\Accounts($c); }); +$container->set('repositories.character', function ($c) { return new \BackendPhp\Api\Repositories\Characters($c); }); +$container->set('repositories.contract', function ($c) { return new \BackendPhp\Api\Repositories\Contracts($c); }); +$container->set('repositories.knowledge', function ($c) { return new \BackendPhp\Api\Repositories\Knowledge($c); }); +$container->set('repositories.world', function ($c) { return new \BackendPhp\Api\Repositories\World($c); }); +$container->set('repositories.market', function ($c) { return new \BackendPhp\Api\Repositories\Market($c); }); +$container->set('repositories.governance', function ($c) { return new \BackendPhp\Api\Repositories\Governance($c); }); + +// Start servers +$http = new Http($loop); +$ws = new WebSocket($loop); + +$http->listen('0.0.0.0', 8080); +$ws->listen('0.0.0.0', 8081); + +echo "Backend PHP server starting on HTTP :8080 and WS :8081\n"; + +$loop->run(); diff --git a/tests/Contract/ContractLifecycleTest.php b/tests/Contract/ContractLifecycleTest.php new file mode 100644 index 0000000..e7e6294 --- /dev/null +++ b/tests/Contract/ContractLifecycleTest.php @@ -0,0 +1,31 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +use PHPUnit\Framework\Assert; +use BackendPhp\Contract\Contract; + +test('contract lifecycle transitions: activate -> pause -> resolve', function () { + $c = new Contract('c1'); + Assert::assertEquals(Contract::STATUS_PENDING, $c->getStatus()); + + $c->activate(); + Assert::assertEquals(Contract::STATUS_ACTIVE, $c->getStatus()); + + $c->pause('STAMINA_DEPLETED'); + Assert::assertEquals(Contract::STATUS_PAUSED, $c->getStatus()); + Assert::assertEquals('STAMINA_DEPLETED', $c->getPauseReason()); + + $c->activate(); + Assert::assertEquals(Contract::STATUS_ACTIVE, $c->getStatus()); + + $c->resolve(); + Assert::assertEquals(Contract::STATUS_RESOLVED, $c->getStatus()); +}); diff --git a/tests/Domain/ExpertiseTest.php b/tests/Domain/ExpertiseTest.php new file mode 100644 index 0000000..67555e2 --- /dev/null +++ b/tests/Domain/ExpertiseTest.php @@ -0,0 +1,29 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +use PHPUnit\Framework\Assert; +use BackendPhp\Domain\ExpertiseCalculator; + +test('weighted expertise computes correctly', function () { + $contributors = [ + ['effort' => 10.0, 'expertise' => 2.5], + ['effort' => 90.0, 'expertise' => 0.8], + ]; + + $weighted = ExpertiseCalculator::weightedExpertise($contributors); + // weighted = 0.1*2.5 + 0.9*0.8 = 0.25 + 0.72 = 0.97 + Assert::assertEqualsWithDelta(0.97, $weighted, 0.0001); +}); + +test('weighted expertise returns 0 for zero effort', function () { + $weighted = ExpertiseCalculator::weightedExpertise([]); + Assert::assertEquals(0.0, $weighted); +}); diff --git a/tests/EffortTest.php b/tests/EffortTest.php new file mode 100644 index 0000000..58d2f3b --- /dev/null +++ b/tests/EffortTest.php @@ -0,0 +1,33 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +use PHPUnit\Framework\Assert; +use BackendPhp\Domain\EffortCalculator; + +test('efficiency calculation returns relevant_stat / reference_value', function () { + $eff = EffortCalculator::calculateEfficiency(12.0, 10.0); + Assert::assertEquals(1.2, $eff); +}); + +test('generates effort = stamina_drained * efficiency', function () { + $effort = EffortCalculator::generateEffort(5.0, 1.2); + Assert::assertEquals(6.0, $effort); +}); + +test('calculateEfficiency throws on invalid reference value', function () { + $this->expectException(InvalidArgumentException::class); + EffortCalculator::calculateEfficiency(10, 0); +}); + +test('generateEffort throws on negative inputs', function () { + $this->expectException(InvalidArgumentException::class); + EffortCalculator::generateEffort(-1, 1); +}); diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php new file mode 100644 index 0000000..74f6368 --- /dev/null +++ b/tests/IntegrationTest.php @@ -0,0 +1,46 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +use BackendPhp\Support\Config; +use BackendPhp\Database\ConnectionPool; +use BackendPhp\Migrations\Runner as MigrationRunner; +use PHPUnit\Framework\Assert; +use PHPUnit\Framework\SkippedTestSuiteError; + +test('migration runner applies SQL files using in-memory sqlite', function () { + $migrationsPath = __DIR__.'/integration_migrations'; + + // Try to create a test PDO connection; skip if it fails in this environment + try { + new \PDO('sqlite::memory:'); + } catch (\PDOException $e) { + throw new SkippedTestSuiteError('Unable to create PDO sqlite connection; skipping integration migration test'); + } + + $config = new Config([ + 'db' => [ + 'dsn' => 'sqlite::memory:', + 'user' => null, + 'pass' => null, + ], + ]); + + $pool = new ConnectionPool($config); + $runner = new MigrationRunner($pool, $migrationsPath); + + // Should run without exceptions + $runner->runPending(); + + $pdo = $pool->getConnection(); + $stmt = $pdo->query("SELECT name FROM sqlite_master WHERE type='table' AND name='example_table'"); + $found = $stmt->fetchColumn(); + Assert::assertEquals('example_table', $found); +}); diff --git a/tests/Part/PartTest.disabled b/tests/Part/PartTest.disabled new file mode 100644 index 0000000..88fe531 --- /dev/null +++ b/tests/Part/PartTest.disabled @@ -0,0 +1,40 @@ + 'c1', + 'started_at' => '2026-02-27T10:00:00Z', + 'owner' => ['id' => 'char1', 'created_at' => '2026-02-01T00:00:00Z'], + ]; + + $contract = new Contract($row); + Assert::assertInstanceOf(Contract::class, $contract); + + // owner should be a Character + Assert::assertInstanceOf(Character::class, $contract->owner); + + $arr = $contract->jsonSerialize(); + Assert::assertArrayHasKey('owner', $arr); + Assert::assertArrayHasKey('created_at', $arr['owner']); +}); + +test('dirty tracking works', function () { + $c = new Contract(['id' => 'x']); + Assert::assertFalse($c->isDirty()); + $c->foo = 'bar'; + Assert::assertTrue($c->isDirty()); + $c->syncOriginal(); + Assert::assertFalse($c->isDirty()); +}); +*/ diff --git a/tests/Reservation/ReservationTest.php b/tests/Reservation/ReservationTest.php new file mode 100644 index 0000000..a750200 --- /dev/null +++ b/tests/Reservation/ReservationTest.php @@ -0,0 +1,27 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +use PHPUnit\Framework\Assert; +use BackendPhp\Reservation\Service as ReservationService; + +test('reservation succeeds when enough inventory', function () { + $svc = new ReservationService(['iron_ingot' => 5]); + $ok = $svc->reserve('iron_ingot', 3); + Assert::assertTrue($ok); + Assert::assertEquals(2, $svc->available('iron_ingot')); +}); + +test('reservation fails when insufficient', function () { + $svc = new ReservationService(['iron_ingot' => 2]); + $ok = $svc->reserve('iron_ingot', 3); + Assert::assertFalse($ok); + Assert::assertEquals(2, $svc->available('iron_ingot')); +}); diff --git a/tests/WorkUnitTest.php b/tests/WorkUnitTest.php new file mode 100644 index 0000000..0579ae4 --- /dev/null +++ b/tests/WorkUnitTest.php @@ -0,0 +1,28 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +use PHPUnit\Framework\Assert; +use BackendPhp\Domain\WorkUnit; + +test('1 minute (60s) equals 1 WU', function () { + $wu = WorkUnit::fromActiveSeconds(60); + Assert::assertEquals(1.0, $wu); +}); + +test('two minutes equals 2 WU', function () { + $wu = WorkUnit::fromActiveSeconds(120); + Assert::assertEquals(2.0, $wu); +}); + +test('fromActiveSeconds throws on negative seconds', function () { + $this->expectException(InvalidArgumentException::class); + WorkUnit::fromActiveSeconds(-5); +}); diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..0e62919 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,57 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +$autoload = __DIR__.'/../vendor/autoload.php'; +if (! file_exists($autoload)) { + fwrite(STDERR, "Composer autoload not found. Run `composer install` first.\n"); + exit(1); +} + +require $autoload; + +// Optional test-time settings +error_reporting(E_ALL); +ini_set('display_errors', '1'); + +// Provide a lightweight test Discord factory so tests can create Discord Part +// subclasses without constructing a full Discord client. This mirrors the +// approach used by DiscordPHP tests where a factory/stub is provided. +\BackendPhp\Support\PartFactory::setDiscordFactory(new class { + public function part(string $class, array $data = [], bool $created = false) + { + $ref = new \ReflectionClass($class); + $instance = $ref->newInstanceWithoutConstructor(); + + if (method_exists($instance, 'fill')) { + $instance->fill($data); + } elseif ($ref->hasProperty('attributes')) { + $propName = 'attributes'; + $setter = function ($val) use ($propName) { + $this->$propName = $val; + }; + $setter = \Closure::bind($setter, $instance, $class); + $setter($data); + } + + if ($ref->hasProperty('created')) { + $propName = 'created'; + $setter = function ($val) use ($propName) { + $this->$propName = $val; + }; + $setter = \Closure::bind($setter, $instance, $class); + $setter($created); + } + + return $instance; + } +}); diff --git a/tests/integration_migrations/001_init.sql b/tests/integration_migrations/001_init.sql new file mode 100644 index 0000000..853a881 --- /dev/null +++ b/tests/integration_migrations/001_init.sql @@ -0,0 +1,4 @@ +CREATE TABLE example_table ( + id INTEGER PRIMARY KEY, + name TEXT +);