diff --git a/.github/workflows/safehouse.yml b/.github/workflows/safehouse.yml new file mode 100644 index 00000000..59639eef --- /dev/null +++ b/.github/workflows/safehouse.yml @@ -0,0 +1,56 @@ +name: Offline safehouse contract +on: + pull_request: + paths: + - 'composer.json' + - 'composer.lock' + - 'src/Civ13/Safehouse/**' + - 'src/Civ13/Slash.php' + - 'src/Civ13/Civ13.php' + - 'tests/safehouse/**' + - 'docs/safehouse-acceptance.md' + - '.github/workflows/safehouse.yml' + push: + paths: + - 'composer.json' + - 'composer.lock' + - 'src/Civ13/Safehouse/**' + - 'src/Civ13/Slash.php' + - 'src/Civ13/Civ13.php' + - 'tests/safehouse/**' + - 'docs/safehouse-acceptance.md' + - '.github/workflows/safehouse.yml' +permissions: + contents: read +jobs: + safehouse: + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + php: ['8.3', '8.4', '8.5'] + runs-on: ${{ matrix.os }} + timeout-minutes: 3 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - run: php tests/safehouse/run.php + - run: php tests/safehouse/economics-driver-test.php + discord-routing: + strategy: + matrix: + php: ['8.3', '8.4', '8.5'] + runs-on: ubuntu-latest + timeout-minutes: 8 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 + - run: composer install --no-dev --prefer-dist --no-interaction --no-progress + - run: php tests/safehouse/discord.php + - run: php tests/safehouse/schema.php > /tmp/safehouse-desired.json diff --git a/config/safehouse.example.php b/config/safehouse.example.php new file mode 100644 index 00000000..cb08fa8f --- /dev/null +++ b/config/safehouse.example.php @@ -0,0 +1,8 @@ + false, + 'safehouse_gateway' => null, +]; diff --git a/docs/safehouse-acceptance.md b/docs/safehouse-acceptance.md new file mode 100644 index 00000000..ca5010d2 --- /dev/null +++ b/docs/safehouse-acceptance.md @@ -0,0 +1,118 @@ +# Disabled safehouse acceptance and host gates + +The safehouse integration remains disabled by default. Local mocks, native fixture artifacts and exact-head CI +are separate from command registration, live Discord delivery and host activation. +Ordinary tests/bootstrap.php loads .env; safehouse runners deliberately bypass it. + +## Source review versus activation + +This PR supplies an optional, disabled source package. Source acceptance requires +reviewed defaults and migration behavior, the strict DTO/registration boundaries, +and passing exact-head offline CI. Existing installations need no configuration +change to retain disabled behavior. Final submitted PR review is a merge gate. +The native compatibility, host, Discord usability and gameplay evidence below +remains required for the corresponding compatibility or activation claim; it is +not a requirement to activate a host before reviewing or merging disabled code. + +## Evidence and remaining scope + +| Scope | Evidence | Remaining gate | +| --- | --- | --- | +| Linking | Actor derived from Discord member; strict challenge/proof payload; signed bound replies; native link artifact accepted | Native owns in-game generation/expiry/one-use proof and one-to-one identity. No live linking claim | +| Identity/condition/FIR | Bot passes native JSON without reconstructing inventory; earlier native economic fixture preserves graph; bot stash.detail routing implements the agreed nested DTO | The earlier inspection fixture exposed root ID/secure only. Current native implementation status is not established by that artifact; obtain an exact-head integrated nested DTO receipt | +| Access/busy/in-raid | Bot never supplies account identity or ownership; native rejects access/revision failures; mocked rejection remains ephemeral | No connected active-raid or multiweek recovery acceptance | +| Economics | Native 19-phase roundtrip manifest pins game1644e49dc46a82e430e16c0cfbe47cb911b8cfd4 and bot470fdfb6c3a407cc272f0c63a494de84e3a638d8: list/purchase/cancel/buyback/replay/interruption status | Pricing is approved native prototype allowlist/table, not production market acceptance | +| Confirmation/pending | Exact token-only payload and explicit confirm=true; strict selectors/revision bounds; original status distinguished from query status; bound recovery JSON displayed | Human Discord usability/registration scope has not been exercised | +| Replay/crash/concurrency | Exact bytes retained; queue/active bounds; process lock/replay/crash mocks; native markers and signed recovery artifacts | Host exclusion of concurrent native writers and actual power-loss ordering | +| Room management/market discovery | Additive room.quote/market.browse/stash.detail DTO agreed and bot routing implemented disabled | Exact-head native/bot integrated fixture acceptance pending; verify room upgrades use one account snapshot and receipt. This ledger does not assert that current native code is absent | +| Audit/retention | Immutable request/reply and native transaction receipts retained; no automatic uncertain deletion | Host audit export/access/retention/ack policy and bounded archival before capacity fills | + +The native economic manifest was inspected as supplied evidence, not rerun by +this bot audit. Graph preservation during settlement does not establish detailed +inspection UI, multiweek persistence or active-raid/corpse-insurance gameplay. + +## Source closeout and evidence handoff + +The source package includes the disabled configuration example, strict command +schema, injected async route, immutable worker/spool transport, recovery handling +and offline regression runners. Current main's portable startup and launcher +directory options are preserved alongside the safehouse defaults. The actual +options-resolver regression checks disabled defaults and independent explicitly +configured directory/safehouse settings without constructing a bot. + +`tests/safehouse/run.php` and `economics-driver-test.php` cover the dependency-free +spool and synthetic economic driver. `tests/safehouse/discord.php` exercises the +installed-library interaction, options and registration boundaries with injected +effects. `tests/safehouse/schema.php` exports desired command metadata only. +The existing safehouse workflow runs these checks in public CI; preserve the +source SHA and job results for each new head. Passing fixtures and a skipped +documentation job do not supply runtime or publication acceptance. + +To close the remaining native integration evidence gate, supply a synthetic +artifact that names the exact native and bot commits, the DTO/crypto contract, +request/reply digests, and observed room quote/manage, market browse and stash +detail results. Include revision/actor rejection and replay/recovery controls. +The existing `native-compatibility.php` checker accepts only the fixed +`inspect-1`/`link-1` fixture, synthetic clock 1000 and its before/after restart +layout. It cannot certify arbitrary room/browse/detail artifacts. Those newer +contracts need a reviewed native producer and an appropriate offline checker; +the synthetic PHP economics-driver test is not native evidence. Record the +producer's exact commit separately because the older fixture manifest has no +source-head field. An older paired receipt cannot silently cover newer contracts. +Connected raid/long-term recovery and host activation gates below +remain separate. This bot-only review does not authorize generating a new game +fixture, accessing game saves, enabling integration or running a live operation. + +## Bounded evidence plan + +- Repeat the existing public CI commands at the reviewed bot head: + `php tests/safehouse/run.php` and + `php tests/safehouse/economics-driver-test.php` in the three-minute job; + `php tests/safehouse/discord.php` and `php tests/safehouse/schema.php` in + the eight-minute dependency job. Effects are temporary files, synthetic PHP + workers and injected Discord transports; no registration or game starts. +- Given a reviewed synthetic inspect/link artifact, run + `php tests/safehouse/native-compatibility.php ` in an + isolated job capped at three minutes. It copies only the two named fixture + sets into temporary storage and removes that copy. Retain the native/bot SHAs, + artifact digest, command and result. This consumes evidence without a game. +- Later native DTO acceptance must first name the producer's actual command and + fixture. The bot-side `economics-driver.php SPOOL MANIFEST` operations are + `inspect`, `browse`, `detail`, `room-quote`, `quote`, `confirm`, `poll` and + `recover`; their detailed arguments are in the + [offline fixture driver contract](safehouse-economic-contract.md#offline-native-fixture-driver). + Bound an approved run to + one synthetic actor, the existing public synthetic key, a new temporary spool, + at most 32 requests and five minutes. Advance only the synthetic clock for + expiry controls. Retain request/reply digests and reject/replay/recovery + observations. No native producer command or execution is authorized here. +- Connected gameplay and live registration require a separate concrete fixture, + ownership/resource window and authorization. Keep them pending until their + observed receipts exist; neither a source merge nor offline artifacts supply + them. + +## Explicit host activation requirements + +Keep safehouse_enabled=false until the concrete host package is reviewed. Supply +an injected AsyncGateway backed by AsyncSpool plus AsyncDriver/ReactTimer on the +existing event loop, the intended civ13_guild_id, and independently authenticated +service key provisioned outside tracked files. The synchronous Client is worker +only. Protect the private spool and all ancestors against untrusted replacement, +symlinks and access; do not point it at a save directory. + +Run exactly one native writer per spool with host-enforced exclusion across +processes/restarts. Marker-before-reply and validated-receipt-before-clear are +required, but do not prove disk flush or power-loss ordering. Never overwrite a +completed reply, clear a forged/uncertain marker, re-sign an economic operation, +or mint another quote/token to resolve uncertainty. Expired operations use fresh +read-only actor/digest-bound status. Agree storage flush/recovery guarantees and +receipt-backed archival/ack policy before enabling; queue saturation fails closed. + +Pin native crypto/handler and PHP dependencies, align clocks to the 120-second +TTL/five-second skew bounds, establish production wallet/treasury/pricing policy, +and retain configured audit receipts. Review the exported command schema against +the intended application's global command snapshot; registration saves only the +named safehouse command and does not bulk overwrite unrelated commands. The +handler additionally restricts use to the configured guild. Perform a separately +authorized bounded registration/restart/live ephemeral interaction check before +claiming live acceptance. No activation, live currency or deployment occurred. diff --git a/docs/safehouse-economic-contract.md b/docs/safehouse-economic-contract.md new file mode 100644 index 00000000..10c42446 --- /dev/null +++ b/docs/safehouse-economic-contract.md @@ -0,0 +1,171 @@ +# Safehouse economics contract v1 + +Additive agreed actions: `stash.detail` exact `{item_id,page}` with page0..31, +`market.browse` exact `{page}` with page0..15, and `room.quote` exact +`{operation:"upgrade",item_ids}`. The first two are read-only with nullable outer +expected_account_revision; room.quote requires outer revision0..1000000. No +revision is supplied in payload. Root IDs are1..80 ASCII identifier characters; +room selectors contain1..16 distinct roots. Native eligibility and required +5/10/15 workshop parts remain game authority. room.manage remains token-only plus +quoted outer revision and Discord confirm=true. Native upgrade consumes parts, +level and original request receipt in one account snapshot, not the shared trade +journal, preserving trade graph/level conservation. + +Detail data is `{account_revision,graph_revision,item_id,total_nodes,nodes,next_page}`; +both revisions are saved account revision. Up to16 nodes/page expose only index, +id,type,parent graph index,found_in_raid,condition `{health,maxhealth}`,material_key, +amount,ammo_refs (contained graph indexes only); unavailable values are null. +Root observations retain id/secure and add node_count/root compact summary. +Browse is8 listings/page, at most128 active listings: listing_id/listing_revision, +price `{currency_id:"civ13.game_credits.v1",amount}`,item_ids,item_count,roots. +No seller identity, arbitrary saved scalar strings or insurance secret is exposed. +Quotes revalidate escrow and do not treat browse observations as purchase authority. +Native rejects `response_too_large` before publishing rather than truncating DTOs. + +This pins the DTO agreed with the native owner through the coordinator. Native +end-to-end bot compatibility remains unverified; this does not enable trades. +It extends the immutable request and +authenticated exact-byte envelope in `safehouse-integration.md`; neither envelope +version changes. Existing game link/read/signing acceptance is reported by the +game owner separately. Prices, wallet bounds and native escrow implementation +remain game-owned dependencies, not values invented by the bot. + +## Quote and confirmation actions + +All quotes require the linked actor's integer `expected_account_revision` in 0..1000000. +The request actor is the authenticated Discord actor, never a payload ckey. +`trade.quote` has exactly one of the following payloads: + +| sale_kind | Other payload field | Confirmation action | +| --- | --- | --- | +| server_buyback | item_ids: array of owned top-level graph root IDs | trade.confirm | +| player_listing | item_ids: array of owned top-level graph root IDs | market.list | +| player_purchase | listing_id: opaque game listing ID | market.buy | +| listing_cancel | listing_id: opaque game listing ID owned by actor | market.cancel | + +No client price, currency, quantity, seller, buyer, item metadata or graph body +is accepted. IDs select existing game records. Listing prices and buyback prices +come exclusively from approved game tables. Missing/unsupported pricing rejects +the quote; no fallback price, fee, exchange rate or substitute item is minted. + +A successful quote's response `data` contains: + +```text +quote_id: opaque string +confirmation_token: opaque game-issued string +confirm_action: one of the confirmation actions above +expected_account_revision: actor revision captured by quote +expires_at: canonical decimal Unix-seconds STRING +price: {currency_id: civ13.game_credits.v1, amount: bounded integer game units} +item_ids: exact top-level graph roots covered +listing_id, listing_revision: present for purchase/cancellation +``` + +Cancellation has no economic price effect; its quote may omit `price`. Quote +issuance does not transfer/reserve items or currency, publish a listing, or change +the account's economic revision. It may durably store the token/quote record. +The token expires at most 120 seconds after issuance, independently of its IPC +envelope TTL. Native tables and wallet rules define acceptable integer bounds. + +Every confirmation payload is exactly `{confirmation_token}`. The outer action, +actor and `expected_account_revision` must match the quote. The Discord route +also requires `confirm=true`; this local UI flag is not game authority and is not +part of the economic payload. Tokens bind the action, actor/account, revisions, +full graph identity including descendants/FIR/condition, immutable approved game +price and expiry. Purchase additionally binds listing/seller/counterparty identity +and listing revision; the handler observes and checks both accounts' revisions. +The token itself conveys no client authority to replace those values. + +## Atomic game effects + +- `trade.confirm`: remove the quoted graph from the seller and credit the + approved server buyback amount once in one durable native transaction. +- `market.list`: reserve the entire graph into game-owned escrow, then publish + the listing with the captured price. Listing visibility cannot precede durable + graph reservation. Failure must not leave a public unbacked listing. +- `market.buy`: atomically debit the buyer, credit the seller, transfer that + exact escrow graph to the buyer and close the listing in one shared durable + receipt. Never approximate this with two independent stash-save writes. +- `market.cancel`: only the seller may return the escrow graph and close the + unfilled listing. Competing buy/cancel can have only one winning receipt. + +Recheck linked ownership, busy/in-raid states of affected accounts, exact account +and listing revisions, graph availability/eligibility, token expiry, approved +pricing, funds, integer overflow and destination capacity at confirmation. +Reject self-purchase. Reject arbitrary descendants, duplicate graph roots and +overlapping parent/child selections. Never duplicate nested IDs, relabel FIR, +repair condition or reconstruct equivalent items. A graph transfers as the game +serialized it. Account revisions advance only as native atomic transactions +require; the response reports resulting authoritative revisions and receipt IDs. + +## Idempotency, expiry and recovery + +The game stores one durable result for the immutable request identity, actor and +digest. Duplicate delivery with identical binding returns that original result +even after the token was consumed or expired. A conflicting actor/body/digest +using the same request ID rejects before effects. A consumed token under a new +request ID rejects, never creates another settlement. Persist business rejections +as terminal receipts as well. A token is consumed as part of the durable winning +transaction, not before an uncommitted write that would strand recoverable work. + +On a crash/uncertain transaction, preserve pending and reconcile its shared +receipt before allowing conflicting effects. A fresh signed read-only +`trade.status` request has payload `{operation_request_id, +operation_request_digest}`. Verify the original receipt's actor ownership before +disclosing it. The outer status refers to completion of the status query; +`data.operation_status` and `data.operation_receipt` refer to the original +economic operation. Never interpret a successful status query as a completed +trade. `operation_status` is pending, committed, rejected or not_found. Not-found +is a query result, not authorization to repeat an economic +operation. Expired IPC requests remain immutable and recover via this fresh +status envelope; no uncertain operation file is overwritten or silently purged. + +## Rejections and pending outcomes + +Agreed stable business `data.code` values: `invalid_payload`, `unlinked`, +`busy`, `in_raid`, `stale_revision`, `item_not_owned`, `item_ineligible`, +`invalid_graph`, `pricing_unavailable`, `quote_expired`, `token_invalid`, +`token_used`, `action_mismatch`, `listing_unavailable`, `self_purchase`, +`insufficient_funds`, `capacity_exceeded`, `request_conflict`. These mean outer +`status=rejected` with no new economic effects. Return game-safe explanatory +text and any safe current revision, never a replacement price or confirmation. +Authentication/body/signature failures are transport failures, not accepted +economic requests. `recovery_required` means pending with no success claim until +the durable transaction resolves. Native integration tests must match these +agreed names and DTOs before compatibility is claimed. + +The agreed native prototype price table is modern_safehouse_prices_v1. These are +prototype game credits and approved allowlisted prices, never Discord currency. +Quote expiry uses decimal text because native DM cannot exactly represent current +Unix epoch numbers. IPC timestamp tokens retain the existing wire format. +Duplicate token replay still requires valid fresh IPC bounds; an expired immutable +economic envelope must recover with fresh read-only status. For not_found the +operation_receipt is absent or null; other statuses carry the original actor and +request-digest-bound durable response when available. + +## Offline native fixture driver + +`tests/safehouse/economics-driver.php` accepts a private temporary spool and a +manifest with `synthetic:true`, the public signing fixture key, and integer +`clock`. It never loads bot/bootstrap or production credentials. Invocation: + +```text +php tests/safehouse/economics-driver.php SPOOL MANIFEST inspect ID ACTOR +php tests/safehouse/economics-driver.php SPOOL MANIFEST browse ID INSPECT_KEY PAGE +php tests/safehouse/economics-driver.php SPOOL MANIFEST detail ID INSPECT_KEY ITEM_ID PAGE +php tests/safehouse/economics-driver.php SPOOL MANIFEST room-quote ID INSPECT_KEY SELECTOR_JSON +php tests/safehouse/economics-driver.php SPOOL MANIFEST quote ID INSPECT_KEY SALE_KIND SELECTOR_JSON +php tests/safehouse/economics-driver.php SPOOL MANIFEST confirm ID QUOTE_KEY +php tests/safehouse/economics-driver.php SPOOL MANIFEST poll FILE_KEY +php tests/safehouse/economics-driver.php SPOOL MANIFEST recover NEW_STATUS_ID ORIGINAL_KEY +``` + +Every enqueue prints its SHA256 file key. Quote revision comes from authenticated +committed inspection `data.revision`. Confirmation action/token/revision/expiry +come exclusively from the authenticated bound quote. Selectors are JSON root-ID +arrays for buyback/list and JSON listing-ID strings for purchase/cancel. The +driver preserves immutable requests through `AsyncSpool`, refuses pending or +expired quotes, and recovers uncertainty only with read-only status. Change the +synthetic clock via the manifest to test expiry; original request bytes remain +unchanged. `economics-driver-test.php` exercises all four actions and meaningful +negative controls in subprocesses; native economic execution remains separate. diff --git a/docs/safehouse-insurance-contract.md b/docs/safehouse-insurance-contract.md new file mode 100644 index 00000000..7cc0769e --- /dev/null +++ b/docs/safehouse-insurance-contract.md @@ -0,0 +1,26 @@ +# Read-only insurance projection + +`insurance.inspect` has exact payload `{ "cursor": null }` initially or an opaque +printable ASCII cursor of 1..256 bytes. Empty cursors are rejected. The existing +outer `expected_account_revision` is nullable. There is no Discord claim action. + +The authoritative response has account_revision (0..1000000), mail_revision and +completed_generation (0..16777215), expiration exactly `to be implemented`, +entries (at most eight), and next_cursor (null or the same cursor syntax). +Each entry has item_id (ASCII identifier 1..80), native catalog type/name +(nullable UTF-8 strings at most 160/128 bytes), condition with health/maxhealth +(finite numbers or null), found_in_raid (boolean or null), returned_generation +and eligible_generation (0..16777215), and state (`pending` or `eligible`). +The decoded signed response remains bounded to 8192 bytes. Proofs, tokens and +arbitrary saved scalar strings are absent. Native stale cursors reject with +`stale_cursor`; the bot never constructs or interprets their HMAC bindings. + +Returns are recorded at the loss round closure and remain locked through one +additional full successful round closure. Restart does not advance generation. +The bot displays native state without deriving eligibility from time or cached +rounds. Logical storage is unlimited; cursor paging and native sharding bound +individual reads. Counter limits are not an entry limit. Expiration has no +active TTL and is explicitly pending a balance decision. + +Mocked projection/routing tests do not prove native sharding, full round closure, +or insurance recovery. Native compatibility is pending a pinned fixture. diff --git a/docs/safehouse-integration.md b/docs/safehouse-integration.md new file mode 100644 index 00000000..51692af5 --- /dev/null +++ b/docs/safehouse-integration.md @@ -0,0 +1,184 @@ +# Discord safehouse integration (provisional, disabled) + +This patch provides a controller, command schema, durable request client, +disabled enqueue/poll authenticated spool and actual DiscordPHP routing hooks. +No commands have been registered, bot started or game saves read by the bot. +Native fixture compatibility is recorded in `safehouse-acceptance.md`; live and +host acceptance remain separate. Existing verifier cache entries provide no trade authority. + +## Version 1 request + +```json +{"protocol":"civ13.safehouse.v1","request_id":"discord-interaction-id","discord_id":"123","action":"trade.confirm","expected_account_revision":4,"payload":{"confirmation_token":"opaque-game-token"},"request_digest":"sha256-of-canonical-envelope-without-digest"} +``` + +Objects sort keys recursively; arrays retain order. Encode UTF-8 JSON without +whitespace using PHP JSON default escaping. The digest binds the immutable +request; it is not authentication. Responses echo protocol, request_id, +discord_id, action and request_digest with status pending, committed or rejected. +Only committed establishes durable game success. A transport timeout remains +pending even when the game may already have committed. Replay the exact request. + +Proposed payloads: link.challenge {challenge_id}; link.complete {challenge_id, +proof}; stash.inspect/room.inspect {page}; room.manage {confirmation_token}; +trade.quote {sale_kind, item_ids or listing_id}; trade.confirm {confirmation_token}; +trade.status {operation_request_id, operation_request_digest}; market.list {confirmation_token}; market.buy +{confirmation_token}; market.cancel {confirmation_token}. The native handler +accepts this versioned schema. No client price or account identity is accepted. +The refined economic proposal is in `safehouse-economic-contract.md` and +`tests/safehouse/economic-contract.json`; native acceptance remains separate. +Quotes bind actor, item graph IDs, revision, approved game price, action and +expiry. Listing and room mutations also require game-issued confirmation. + +Link challenges originate from an authenticated in-game account and are bounded, +short-lived and single-use; the trusted service adapter binds the Discord actor. +Discord alone cannot establish account ownership. Busy/in-raid/unauthorized, +expired or stale requests must be rejected by the game. Nested IDs, FIR and +condition remain game values. The bot never reconstructs inventories. + +## Proposed local spool transport + +Inject a transport using a private, configured IPC directory, not a save path. +Use one immutable request file per request ID, written to a private temporary +file then atomically renamed. A separate authenticated adapter signs the exact +request bytes using a service credential; the game validates that credential +independently of discord_id. No configured endpoint or credential is invented +by this patch. Replies must bind the authenticated request digest and identity. +Bound request size, queue count, expiry, polling and timeout before enabling. +The game owns one bounded queue and idempotency ledger; concurrent/replayed +requests cannot settle twice. Multi-party settlement requires one durable shared +transaction receipt and recovery, not separate account-save writes. + +`AsyncSpool` publishes and polls without sleeping or launching a process. The +host must schedule polling with React timers and stop at its own bounded timeout. +Files are `SHA256(request_id).request` and `.reply`. The private spool directory +must exist. The game must retain immutable request files; archival is a separate +operator policy, never deletion during consumption. + +The authenticated envelope is `{protocol, issued_at, expires_at, body, mac}`. +Protocol is `civ13.safehouse.ipc.v1`; body is base64 of the exact UTF-8 JSON +request/reply bytes. MAC is lowercase hex HMAC-SHA256 of these exact bytes: + +```text +civ13.safehouse.ipc.v1\n\n\n\n +``` + +Each `\n` denotes one literal LF byte; there is no trailing LF. Direction is +`request` or `reply` and prevents reflection. Validity is at most 120 seconds; +future issuance skew is at most five seconds. Replies with expired envelopes +remain pending. The game must reject expired/unlinked/stale requests, enforce +bounded one-use challenges and validate service authentication before processing +actor claims. PHP uses built-in `hash_hmac`; game verification requires its pinned +crypto adapter and must not spawn a process per request. Digest is preserved from +the trusted bot envelope and is never authentication. + +Independent Python-generated signing bytes are pinned in +`tests/safehouse/signing-fixture.json` with a synthetic public fixture key. No +real key appears in code, fixtures, arguments or environment examples. This +fixture is the proposed game compatibility input, not game acceptance evidence. + +Expired envelopes do not imply economic failure. `recover(file_key, +new_status_request_id)` enqueues a newly signed read-only `trade.status` request +whose payload is `{operation_request_id, operation_request_digest}` and whose +actor matches the original operation. Its fresh envelope has an independent +request ID/digest, while the economic operation and original file remain +immutable. The game authenticates it and checks ownership of the original +receipt before returning its outcome. Do not reissue an economic operation with +a fresh ID after a timeout or envelope expiry. Status queries consume bounded +queue capacity and do not authorize deleting uncertain operation files. + +## Local operation and limits + +Run `C:/php/php.exe tests/safehouse/run.php`. It bypasses the ordinary test +bootstrap, which reads `.env`. Tests use injected game transport and temporary +journals with no Discord/network/bootstrap effects. Constructor `enabled` is +false by default. Hosting code must use trusted Discord actor/interaction IDs +and ephemeral responses; the disabled routing configuration is documented below. + +The journal serializes local requests with an OS file lock, persists before +transport delivery and caches terminal replies. It bounds records at 1000 and +fails closed when full; archive only under an operator-approved game receipt +retention policy. The transport must enforce a finite timeout. Blocking transport +must not run on the shared Discord event loop. No live spool, game-side checks, +production crash durability or live command registration has been verified. +Isolated native wallet/escrow acceptance is recorded separately. Activation depends on host compatibility, +authenticated async adapter, wallet/escrow and identity reconciliation. + +Native cross-process tests additionally prove local same-ID delivery serialization +and exact replay after a worker exits after mock game commit. The mock game's +durable receipt establishes one synthetic settlement; this does not prove the +production game's transaction durability. Local lock acquisition is bounded at +two seconds, decoded request/reply bodies at 8192 bytes. Sealed wire is at most +12000 bytes, and the entire spool file at most12001 bytes before trimming outer +JSON whitespace. LF and historical Windows CRLF are accepted only within that +file cap; authenticated decoded body bytes are never trimmed. A busy lock returns pending. + +For an asynchronous adapter, split durable enqueue and reply handling: hold flock +only while inserting immutable serialized envelope bytes and checking known +receipts, atomically publish a signed IPC file, then poll bounded reply paths with +the React event-loop timer. Resolve timeout as pending, retaining the same request +bytes. Validate authenticated reply binding before terminal journal storage. Do +not call this synchronous client from a Discord interaction callback; the current +implementation belongs in an isolated worker; Discord uses AsyncDriver and the spool primitives instead. + +## Disabled Discord routing activation package + +`config/safehouse.example.php` declares `safehouse_enabled=false` and +`safehouse_gateway=null`. Civ13's actual options resolver validates these values. +When explicitly enabled with an injected `AsyncGateway` and configured +`civ13_guild_id`, Slash binds only the `safehouse` callback and reconciles only +that named global command, preserving other commands. It never bulk overwrites +the command repository. Disabled mode neither binds nor saves a safehouse command. +Registered stale commands are not silently deleted when disabled. + +The actual route rejects wrong-guild/absent-member requests, derives actor from +Discord membership and request ID from the interaction, and requires both a game +confirmation token/revision and explicit `confirm=true` for irreversible actions. +It defers ephemerally using DiscordPHP `acknowledgeWithResponse(true)` before +enqueueing; a failed acknowledgement sends no request. All later updates retain +that private response and disable mentions. Pending and transport errors report +uncertainty; rejected outcomes remain game rejections. JSON views are bounded and +page requests are game-owned; rendering does not edit metadata or inventories. + +Construct `AsyncDriver(spool, ReactTimer::repeat(loop))` only after crypto/game +compatibility acceptance. It deduplicates active identical requests, bounds 64 +active requests and 40 quarter-second polls by default, cancels terminal/timeout +timers and resolves timeouts as pending. No blocking synchronous client runs in +this route. It is an injected timer driver, not automatic production activation. + +Run `php tests/safehouse/discord.php ` for offline +actual installed-library signature and handler tests. The runner loads Composer +autoload only; it bypasses bot constructors and `.env`, creates inert reflection +fixtures for Slash/Discord and mocks interaction calls/game gateway. Run +`php tests/safehouse/schema.php` to export the exact desired schema without a bot. +CI tests Discord routing against resolved Composer dependencies separately from +the dependency-free Windows/Linux spool regressions. No live registration or +Discord send was performed. +# Native reply publication barrier + +For a native writer without atomic rename, create `.reply.pending` before +writing any `.reply` bytes. The bot checks marker presence before and after +authenticated reply parsing with refreshed filesystem metadata. Any marker, +including empty or malformed content, keeps the request pending; symlinks reject. +Missing, partial, invalid, expired or incorrectly bound replies never mean success. +The bot never clears a marker. One game writer must serialize publication per key, +never overwrite a completed reply, validate its signed completed receipt before +removing the marker, and on restart compare the original durable operation receipt +before clearing a crash marker. Expired replies recover through fresh status. +Native power-loss flush ordering remains a separate acceptance requirement; +presence barriers do not themselves prove durable storage ordering. + +The isolated native fixture at game commit +`6ae539e5940a6a84c22163b1ca57cc662cbaf60f` was read by the bot adapter with +`php tests/safehouse/native-compatibility.php `. +Both native inspection and link receipts passed marker/restart recovery, reply +expiry, immutable original bytes/timewindow, wrong-MAC and signed wrong-actor +negative controls. The runner validates synthetic manifest metadata and copies +only the two named request/reply sets to temporary storage. This proves supplied +native artifacts work with PHP polling; it does not prove economic command +round trips, host writer exclusion or power-loss durability. The manifest has no +source-head field, so the generating checkout head must be verified separately. + +Native stash.inspect and room.inspect require exactly {page: integer 0..7}. The +Discord controller rejects omitted, extra, noninteger or out-of-range fields +before transport. The synthetic economics driver emits page 0 for inspection. diff --git a/src/Civ13/Civ13.php b/src/Civ13/Civ13.php index 934d231b..27a09e3f 100644 --- a/src/Civ13/Civ13.php +++ b/src/Civ13/Civ13.php @@ -463,6 +463,8 @@ private function resolveOptions(array &$options = []): array $resolver ->setDefined([ + 'safehouse_enabled', + 'safehouse_gateway', 'welcome_message', 'logger', 'onFulfilledDefault', @@ -507,6 +509,8 @@ private function resolveOptions(array &$options = []): array 'webserver_url', ]) ->setDefaults([ + 'safehouse_enabled' => false, + 'safehouse_gateway' => null, 'discover_external_ip' => true, 'welcome_message' => '', 'logger' => null, @@ -551,6 +555,8 @@ private function resolveOptions(array &$options = []): array 'webserver_url' => 'www.valzargaming.com', ]) ->setAllowedTypes('welcome_message', 'string') + ->setAllowedTypes('safehouse_enabled', 'bool') + ->setAllowedTypes('safehouse_gateway', ['null', \Civ13\Safehouse\AsyncGateway::class]) ->setAllowedTypes('restart_mode', 'string') ->setAllowedValues('restart_mode', ['legacy', 'supervised']) ->setAllowedTypes('logger', ['null', Logger::class]) diff --git a/src/Civ13/Safehouse/AsyncDriver.php b/src/Civ13/Safehouse/AsyncDriver.php new file mode 100644 index 00000000..fa399ace --- /dev/null +++ b/src/Civ13/Safehouse/AsyncDriver.php @@ -0,0 +1,54 @@ + cancellation callable; ReactTimer supplies + * the production implementation. Finite polling never blocks Discord's loop. */ +final class AsyncDriver implements AsyncGateway +{ + private array $active = []; + public function __construct(private AsyncSpool $spool, private \Closure $repeat, private int $maxPolls = 40, private int $capacity = 64) + { + if ($maxPolls < 1 || $maxPolls > 240 || $capacity < 1 || $capacity > 1000) { throw new \InvalidArgumentException('Invalid async polling bounds'); } + } + + public function submit(array $request): PromiseInterface + { + $identity = $request['request_id']; + $bytes = json_encode($request, JSON_THROW_ON_ERROR); + if (isset($this->active[$identity])) { + if ($this->active[$identity]['bytes'] !== $bytes) { throw new \LogicException('Active request collision'); } + return $this->active[$identity]['promise']; + } + if (count($this->active) >= $this->capacity) { throw new \RuntimeException('Async request capacity reached'); } + $id = $this->spool->enqueue($request); + $deferred = new Deferred(); + $promise = $deferred->promise(); + $this->active[$identity] = ['bytes' => $bytes, 'promise' => $promise]; + $polls = 0; + $cancel = null; + $tick = function () use ($id, $identity, $deferred, &$polls, &$cancel): void { + try { + $outcome = $this->spool->poll($id); + if ($outcome['status'] === 'pending' && ++$polls < $this->maxPolls) { return; } + if ($cancel !== null) { $cancel(); } + unset($this->active[$identity]); + $deferred->resolve($outcome); + } catch (\Throwable $error) { + if ($cancel !== null) { $cancel(); } + unset($this->active[$identity]); + $deferred->reject($error); + } + }; + try { + $cancel = ($this->repeat)(0.25, $tick); + if (!is_callable($cancel)) { throw new \LogicException('Timer cancellation callable required'); } + } catch (\Throwable $error) { + unset($this->active[$identity]); + $deferred->reject($error); + } + return $promise; + } +} diff --git a/src/Civ13/Safehouse/AsyncGateway.php b/src/Civ13/Safehouse/AsyncGateway.php new file mode 100644 index 00000000..f7fe4c78 --- /dev/null +++ b/src/Civ13/Safehouse/AsyncGateway.php @@ -0,0 +1,8 @@ +clock = $clock ?? static fn (): int => time(); + if (strlen($key) < 32) { throw new \InvalidArgumentException('Service key must have at least 32 bytes'); } + if (!is_dir($directory) || is_link($directory)) { throw new \InvalidArgumentException('Private existing spool directory required'); } + $parent = $directory; + while (dirname($parent) !== $parent) { + if (is_link($parent)) { throw new \InvalidArgumentException('Symlink spool ancestor rejected'); } + $parent = dirname($parent); + } + $this->directory = realpath($directory); + } + + /** Returns the opaque file key. Repeated ID must contain identical bytes. */ + public function enqueue(array $request): string + { + if (!$this->enabled) { throw new \LogicException('Safehouse spool disabled'); } + foreach (['protocol', 'request_id', 'discord_id', 'action', 'request_digest'] as $field) { + if (!is_string($request[$field] ?? null)) { throw new \InvalidArgumentException('Invalid request envelope'); } + } + if ($request['protocol'] !== Client::PROTOCOL || !in_array($request['action'], Client::ACTIONS, true)) { throw new \InvalidArgumentException('Unsupported request'); } + $bytes = json_encode($request, JSON_THROW_ON_ERROR); + if (strlen($bytes) > 8192) { throw new \InvalidArgumentException('Spool request too large'); } + $id = hash('sha256', $request['request_id']); + $path = $this->path($id, 'request'); + $lockPath = $this->directory.'/queue.lock'; + if (is_link($lockPath)) { throw new \RuntimeException('Symlink queue lock rejected'); } + $lock = fopen($lockPath, 'c+'); + if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) { + if (is_resource($lock)) { fclose($lock); } + throw new \RuntimeException('Queue busy; retry unchanged request'); + } + try { + if (is_file($path)) { + $existing = $this->read($path, 'request'); + if (base64_decode($existing['body'], true) !== $bytes) { throw new \LogicException('Request ID collision'); } + return $id; + } + if (count(glob($this->directory.'/*.request')) >= $this->capacity) { throw new \RuntimeException('Queue capacity reached'); } + $now = ($this->clock)(); + $envelope = self::seal($bytes, 'request', $now, $now + 120, $this->key); + $temporary = $this->directory.'/'.bin2hex(random_bytes(16)).'.tmp'; + $stream = fopen($temporary, 'xb'); + if ($stream === false) { throw new \RuntimeException('Cannot create spool temporary'); } + try { + $encoded = json_encode($envelope, JSON_THROW_ON_ERROR); + if (fwrite($stream, $encoded) !== strlen($encoded) || !fflush($stream) || !fsync($stream)) { throw new \RuntimeException('Cannot persist spool request'); } + } finally { fclose($stream); } + if (!rename($temporary, $path)) { @unlink($temporary); throw new \RuntimeException('Cannot publish spool request'); } + return $id; + } finally { flock($lock, LOCK_UN); fclose($lock); } + } + + /** Missing/expired replies remain pending. Authentication failures throw. */ + public function poll(string $id): array + { + if (!$this->enabled) { throw new \LogicException('Safehouse spool disabled'); } + $requestEnvelope = $this->read($this->path($id, 'request'), 'request'); + $request = json_decode(base64_decode($requestEnvelope['body'], true), true, 32, JSON_THROW_ON_ERROR); + if ($this->replyPending($id)) { return ['status' => 'pending', 'request_id' => $request['request_id'], 'reason' => 'reply_write_pending']; } + $replyPath = $this->path($id, 'reply'); + if (!is_file($replyPath)) { return ['status' => 'pending', 'request_id' => $request['request_id']]; } + $replyEnvelope = $this->read($replyPath, 'reply'); + if ($replyEnvelope['expires_at'] < ($this->clock)()) { return ['status' => 'pending', 'request_id' => $request['request_id'], 'reason' => 'expired_reply']; } + $reply = json_decode(base64_decode($replyEnvelope['body'], true), true, 32, JSON_THROW_ON_ERROR); + Client::validateReply($reply, $request); + if ($this->replyPending($id)) { return ['status' => 'pending', 'request_id' => $request['request_id'], 'reason' => 'reply_write_pending']; } + return $reply; + } + + /** Presence only: malformed/empty markers also block success. Only the game + * writer may clear its barrier after validating the durable signed receipt. */ + private function replyPending(string $id): bool + { + clearstatcache(); + return @lstat($this->path($id, 'reply.pending')) !== false; + } + + /** Fresh authenticated read-only status request for an expired/uncertain + * operation. Never re-signs or reissues that economic operation. The game + * checks the actor against the original durable receipt before disclosure. */ + public function recover(string $id, string $statusRequestId): string + { + if (!$this->enabled) { throw new \LogicException('Safehouse spool disabled'); } + $originalEnvelope = $this->read($this->path($id, 'request'), 'request'); + $original = json_decode(base64_decode($originalEnvelope['body'], true), true, 32, JSON_THROW_ON_ERROR); + if ($statusRequestId === $original['request_id']) { throw new \InvalidArgumentException('Recovery needs a new status request ID'); } + $request = ['protocol' => Client::PROTOCOL, 'request_id' => $statusRequestId, 'discord_id' => $original['discord_id'], 'action' => 'trade.status', 'expected_account_revision' => null, 'payload' => ['operation_request_id' => $original['request_id'], 'operation_request_digest' => $original['request_digest']]]; + $request['request_digest'] = hash('sha256', json_encode($request, JSON_THROW_ON_ERROR)); + return $this->enqueue($request); + } + + /** Public for independent fixture adapters; signing never authorizes a trade. */ + public static function seal(string $bytes, string $direction, int $issued, int $expires, string $key): array + { + if (strlen($bytes) > 8192) { throw new \InvalidArgumentException('Decoded IPC body exceeds 8192 bytes'); } + $body = base64_encode($bytes); + $signed = self::IPC_PROTOCOL."\n".$direction."\n".$issued."\n".$expires."\n".$body; + return ['protocol' => self::IPC_PROTOCOL, 'issued_at' => $issued, 'expires_at' => $expires, 'body' => $body, 'mac' => hash_hmac('sha256', $signed, $key)]; + } + + private function path(string $id, string $suffix): string + { + if (!preg_match('/^[a-f0-9]{64}$/D', $id)) { throw new \InvalidArgumentException('Invalid spool file key'); } + $path = $this->directory.'/'.$id.'.'.$suffix; + if (is_link($path)) { throw new \RuntimeException('Symlink spool entry rejected'); } + return $path; + } + + private function read(string $path, string $direction): array + { + clearstatcache(true, $path); + if (!is_file($path) || filesize($path) > 12001) { throw new \RuntimeException('Missing or oversized spool entry'); } + $wire = file_get_contents($path); + // Native text2file and historical Windows fixture writers may append + // CRLF. Bound the entire file first; only outer JSON whitespace is trim. + $wire = trim($wire); + if (strlen($wire) > 12000) { throw new \UnexpectedValueException('IPC wire exceeds 12000 bytes'); } + $envelope = json_decode($wire, true, 32, JSON_THROW_ON_ERROR); + if (($envelope['protocol'] ?? null) !== self::IPC_PROTOCOL || !is_int($envelope['issued_at'] ?? null) || !is_int($envelope['expires_at'] ?? null) || !is_string($envelope['body'] ?? null) || !is_string($envelope['mac'] ?? null) || base64_decode($envelope['body'], true) === false) { throw new \UnexpectedValueException('Invalid authenticated envelope'); } + if ($envelope['expires_at'] < $envelope['issued_at'] || $envelope['expires_at'] - $envelope['issued_at'] > 120 || $envelope['issued_at'] > ($this->clock)() + 5) { throw new \UnexpectedValueException('Invalid envelope time bound'); } + $decoded = base64_decode($envelope['body'], true); + if (strlen($decoded) > 8192 || base64_encode($decoded) !== $envelope['body']) { throw new \UnexpectedValueException('Noncanonical or oversized IPC body'); } + $expected = self::seal(base64_decode($envelope['body'], true), $direction, $envelope['issued_at'], $envelope['expires_at'], $this->key); + if (!hash_equals($expected['mac'], $envelope['mac'])) { throw new \UnexpectedValueException('Invalid service authentication'); } + return $envelope; + } +} diff --git a/src/Civ13/Safehouse/Client.php b/src/Civ13/Safehouse/Client.php new file mode 100644 index 00000000..f4ddad9e --- /dev/null +++ b/src/Civ13/Safehouse/Client.php @@ -0,0 +1,142 @@ +enabled) { + throw new \LogicException('Safehouse integration is disabled'); + } + $request = self::envelope($requestId, $discordId, $action, $payload, $expectedAccountRevision); + // Serialize local delivery and persist before external effects. A timeout retains + // the exact request for replay; only the game can deduplicate settlement. + $lock = fopen($this->journal.'.lock', 'c+'); + if ($lock === false) { + throw new \RuntimeException('Cannot acquire safehouse journal lock'); + } + $deadline = microtime(true) + 2; + while (!flock($lock, LOCK_EX | LOCK_NB)) { + if (microtime(true) >= $deadline) { + fclose($lock); + return ['protocol' => self::PROTOCOL, 'request_id' => $requestId, 'status' => 'pending', 'reason' => 'local_delivery_busy']; + } + usleep(10000); + } + try { + return $this->deliver($request, $requestId); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + public static function envelope(string $requestId, string $discordId, string $action, array $payload, ?int $expectedAccountRevision): array + { + if (!preg_match('/^[A-Za-z0-9_-]{1,96}$/D', $requestId) || !preg_match('/^[0-9]{1,22}$/D', $discordId) || !in_array($action, self::ACTIONS, true) || ($expectedAccountRevision !== null && ($expectedAccountRevision < 0 || $expectedAccountRevision > 1000000))) { + throw new \InvalidArgumentException('Invalid request identity/action/revision'); + } + $request = ['protocol' => self::PROTOCOL, 'request_id' => $requestId, 'discord_id' => $discordId, 'action' => $action, 'expected_account_revision' => $expectedAccountRevision, 'payload' => self::canonical($payload)]; + $encoded = json_encode($request, JSON_THROW_ON_ERROR); + if (strlen($encoded) > 8192) { + throw new \InvalidArgumentException('Request exceeds safehouse size bound'); + } + $digest = hash('sha256', $encoded); + $request['request_digest'] = $digest; + return $request; + } + + private function deliver(array $request, string $requestId): array + { + $records = is_file($this->journal) ? json_decode(file_get_contents($this->journal), true, 64, JSON_THROW_ON_ERROR) : []; + if (!is_array($records)) { + throw new \RuntimeException('Invalid safehouse journal'); + } + if (isset($records[$requestId])) { + if ($records[$requestId]['request'] !== $request) { + throw new \LogicException('Request ID already bound to another request'); + } + if (isset($records[$requestId]['response']) && $records[$requestId]['response']['status'] !== 'pending') { + self::validateReply($records[$requestId]['response'], $request); + return $records[$requestId]['response']; + } + } else { + if (count($records) >= $this->capacity) { + throw new \RuntimeException('Safehouse journal capacity reached; archival required'); + } + $records[$requestId] = ['request' => $request]; + $this->persist($records); + } + try { + $response = $this->transport->exchange($request); + } catch (\Throwable $error) { + return ['protocol' => self::PROTOCOL, 'request_id' => $requestId, 'status' => 'pending', 'reason' => 'transport_unresolved']; + } + self::validateReply($response, $request); + $records[$requestId]['response'] = $response; + $this->persist($records); + return $response; + } + + /** Validates game receipts, including cached receipts; local pending notices are separate. */ + public static function validateReply(array $response, array $request): void + { + foreach (['protocol', 'request_id', 'discord_id', 'action', 'request_digest'] as $key) { + if (($response[$key] ?? null) !== $request[$key]) { + throw new \UnexpectedValueException('Game response binding mismatch: '.$key); + } + } + if (!in_array($response['status'] ?? null, ['pending', 'committed', 'rejected'], true)) { + throw new \UnexpectedValueException('Unknown game response status'); + } + if (!array_key_exists('reason', $response) || !is_string($response['reason']) || !array_key_exists('data', $response) || !is_array($response['data'])) { + throw new \UnexpectedValueException('Invalid game response reason/data'); + } + if (strlen(json_encode($response, JSON_THROW_ON_ERROR)) > 8192) { + throw new \UnexpectedValueException('Game response exceeds safehouse size bound'); + } + } + + private function persist(array $records): void + { + $temporary = $this->journal.'.'.bin2hex(random_bytes(8)).'.tmp'; + $stream = fopen($temporary, 'xb'); + if ($stream === false) { + throw new \RuntimeException('Cannot create journal snapshot'); + } + try { + $bytes = json_encode($records, JSON_THROW_ON_ERROR); + if (fwrite($stream, $bytes) !== strlen($bytes) || !fflush($stream) || !fsync($stream)) { + throw new \RuntimeException('Cannot persist journal snapshot'); + } + } finally { + fclose($stream); + } + if (!rename($temporary, $this->journal)) { + @unlink($temporary); + throw new \RuntimeException('Cannot replace journal snapshot'); + } + } + + private static function canonical(array $value): array + { + if (!array_is_list($value)) { + ksort($value, SORT_STRING); + } + foreach ($value as &$entry) { + if (is_array($entry)) { + $entry = self::canonical($entry); + } elseif (!is_scalar($entry) && $entry !== null) { + throw new \InvalidArgumentException('Payload must contain JSON values'); + } + } + return $value; + } +} diff --git a/src/Civ13/Safehouse/Controller.php b/src/Civ13/Safehouse/Controller.php new file mode 100644 index 00000000..0e824764 --- /dev/null +++ b/src/Civ13/Safehouse/Controller.php @@ -0,0 +1,112 @@ + 'safehouse', 'description' => 'Manage your game safehouse', 'type' => 1, 'contexts' => [0], 'integration_types' => [0], 'options' => [ + ['name' => 'action', 'description' => 'Game-authoritative action', 'type' => 3, 'required' => true, 'choices' => array_map(fn ($action) => ['name' => $action, 'value' => $action], Client::ACTIONS)], + ['name' => 'payload', 'description' => 'Action JSON (confirmation token for irreversible actions)', 'type' => 3, 'required' => true], + ['name' => 'revision', 'description' => 'Expected game account revision', 'type' => 4, 'required' => false], + ['name' => 'confirm', 'description' => 'Confirm the reviewed game quote for an irreversible action', 'type' => 5, 'required' => false], + ]]; + } + + public function handle(string $interactionId, string $actorDiscordId, string $action, string $json, ?int $revision): array + { + $request = self::prepare($interactionId, $actorDiscordId, $action, $json, $revision); + return ['ephemeral' => true, 'outcome' => $this->client->request($interactionId, $actorDiscordId, $action, $request['payload'], $revision)]; + } + + public static function prepare(string $interactionId, string $actorDiscordId, string $action, string $json, ?int $revision): array + { + if (strlen($json) > 4096) { + throw new \InvalidArgumentException('Payload too large'); + } + $payload = json_decode($json, true, 32, JSON_THROW_ON_ERROR); + if (!is_array($payload) || !str_starts_with(ltrim($json), '{')) { + throw new \InvalidArgumentException('Payload must be a JSON object'); + } + foreach (['discord_id', 'ckey', 'account_id', 'price', 'currency', 'items'] as $authority) { + if (array_key_exists($authority, $payload)) { + throw new \InvalidArgumentException('Game authority cannot be supplied: '.$authority); + } + } + if (in_array($action, ['stash.inspect', 'room.inspect', 'stash.detail', 'market.browse'], true)) { + $pageMax = match ($action) { 'stash.detail' => 31, 'market.browse' => 15, default => 7 }; + if (!is_int($payload['page'] ?? null) || $payload['page'] < 0 || $payload['page'] > $pageMax) { throw new \InvalidArgumentException('Invalid observation page'); } + } + $fields = match ($action) { + 'insurance.inspect' => ['cursor'], + 'link.challenge' => ['challenge_id'], + 'link.complete' => ['challenge_id', 'proof'], + 'stash.inspect', 'room.inspect' => ['page'], + 'stash.detail' => ['item_id', 'page'], + 'market.browse' => ['page'], + 'room.quote' => ['item_ids', 'operation'], + 'room.manage', 'trade.confirm', 'market.list', 'market.buy', 'market.cancel' => ['confirmation_token'], + 'trade.status' => ['operation_request_digest', 'operation_request_id'], + 'trade.quote' => match ($payload['sale_kind'] ?? null) { + 'server_buyback', 'player_listing' => ['item_ids', 'sale_kind'], + 'player_purchase', 'listing_cancel' => ['listing_id', 'sale_kind'], + default => throw new \InvalidArgumentException('Unknown sale kind'), + }, + default => throw new \InvalidArgumentException('Unknown action'), + }; + $keys = array_keys($payload); sort($keys); sort($fields); + if ($keys !== $fields) { throw new \InvalidArgumentException('Unexpected or missing action payload fields'); } + foreach ($payload as $field => $value) { + if ($action === 'insurance.inspect' && $field === 'cursor') { self::cursor($value); continue; } + if ($field === 'page') { continue; } + if ($field === 'item_ids') { + if (!is_array($value) || !array_is_list($value) || count($value) < 1 || count($value) > 16 || count(array_unique($value, SORT_REGULAR)) !== count($value)) { throw new \InvalidArgumentException('Select 1..16 distinct root IDs'); } + foreach ($value as $id) { if (!is_string($id) || !preg_match('/^[A-Za-z0-9_-]{1,80}$/D', $id)) { throw new \InvalidArgumentException('Invalid graph root ID'); } } + } elseif (!is_string($value) || !preg_match('/^[A-Za-z0-9_-]{1,128}$/D', $value)) { throw new \InvalidArgumentException('Invalid game-issued identifier'); } + } + if ($action === 'stash.detail' && strlen($payload['item_id']) > 80) { throw new \InvalidArgumentException('Invalid graph root ID'); } + if ($action === 'room.quote' && $payload['operation'] !== 'upgrade') { throw new \InvalidArgumentException('Unknown room operation'); } + if ($action === 'trade.status' && !preg_match('/^[a-f0-9]{64}$/D', $payload['operation_request_digest'])) { throw new \InvalidArgumentException('Invalid original request digest'); } + if (in_array($action, ['trade.quote', 'room.quote'], true) && $revision === null) { throw new \InvalidArgumentException('Quote requires expected account revision'); } + if (in_array($action, ['trade.confirm', 'market.list', 'market.buy', 'market.cancel', 'room.manage'], true) && (!is_string($payload['confirmation_token'] ?? null) || $payload['confirmation_token'] === '' || $revision === null)) { + throw new \InvalidArgumentException('Game confirmation token and revision required'); + } + return Client::envelope($interactionId, $actorDiscordId, $action, $payload, $revision); + } + + private static function cursor(mixed $value): void + { + if ($value !== null && (!is_string($value) || !preg_match('/^[!-~]{1,256}$/D', $value))) { throw new \InvalidArgumentException('Invalid insurance cursor'); } + } + + /** Validate the native projection without deriving eligibility from local clocks. */ + public static function insuranceProjection(array $data): array + { + $exact = static function (array $object, array $fields): void { $keys = array_keys($object); sort($keys); sort($fields); if ($keys !== $fields) { throw new \UnexpectedValueException('Invalid insurance projection fields'); } }; + $integer = static function (mixed $value, int $max): void { if (!is_int($value) || $value < 0 || $value > $max) { throw new \UnexpectedValueException('Invalid insurance counter'); } }; + $exact($data, ['account_revision', 'mail_revision', 'completed_generation', 'expiration', 'entries', 'next_cursor']); + $integer($data['account_revision'], 1000000); + foreach (['mail_revision', 'completed_generation'] as $field) { $integer($data[$field], 16777215); } + self::cursor($data['next_cursor']); + if ($data['expiration'] !== 'to be implemented' || !is_array($data['entries']) || !array_is_list($data['entries']) || count($data['entries']) > 8) { throw new \UnexpectedValueException('Invalid insurance projection bounds'); } + foreach ($data['entries'] as $entry) { + if (!is_array($entry)) { throw new \UnexpectedValueException('Invalid insurance entry'); } + $exact($entry, ['item_id', 'type', 'name', 'condition', 'found_in_raid', 'returned_generation', 'eligible_generation', 'state']); + if (!is_string($entry['item_id']) || !preg_match('/^[A-Za-z0-9_-]{1,80}$/D', $entry['item_id'])) { throw new \UnexpectedValueException('Invalid insurance item identity'); } + foreach (['type' => 160, 'name' => 128] as $field => $max) { $value = $entry[$field]; if ($value !== null && (!is_string($value) || strlen($value) > $max || !preg_match('//u', $value))) { throw new \UnexpectedValueException('Invalid insurance catalog string'); } } + if (!is_array($entry['condition'])) { throw new \UnexpectedValueException('Invalid insurance condition'); } + $exact($entry['condition'], ['health', 'maxhealth']); + foreach ($entry['condition'] as $value) { if ($value !== null && ((!is_int($value) && !is_float($value)) || !is_finite((float) $value))) { throw new \UnexpectedValueException('Invalid insurance condition'); } } + if ($entry['found_in_raid'] !== null && !is_bool($entry['found_in_raid'])) { throw new \UnexpectedValueException('Invalid insurance provenance'); } + foreach (['returned_generation', 'eligible_generation'] as $field) { $integer($entry[$field], 16777215); } + if (!in_array($entry['state'], ['pending', 'eligible'], true)) { throw new \UnexpectedValueException('Invalid insurance state'); } + } + if (strlen(json_encode($data, JSON_THROW_ON_ERROR)) > 8192) { throw new \UnexpectedValueException('Invalid insurance projection size'); } + return $data; + } +} diff --git a/src/Civ13/Safehouse/DiscordRoute.php b/src/Civ13/Safehouse/DiscordRoute.php new file mode 100644 index 00000000..87a242a2 --- /dev/null +++ b/src/Civ13/Safehouse/DiscordRoute.php @@ -0,0 +1,76 @@ +enabled) { throw new \LogicException('Safehouse integration is disabled.'); } + if ($this->guildId === '' || (string) $interaction->guild_id !== $this->guildId || !$interaction->member || !$interaction->member->id) { throw new \LogicException('Use safehouse in its configured server.'); } + $options = $interaction->data->options; + $action = $options['action']->value ?? ''; + $payload = $options['payload']->value ?? '{}'; + $revision = $options['revision']->value ?? null; + if (!is_string($action) || !is_string($payload) || ($revision !== null && !is_int($revision))) { throw new \InvalidArgumentException('Invalid safehouse options.'); } + if (in_array($action, ['trade.confirm', 'market.list', 'market.buy', 'market.cancel', 'room.manage'], true) && ($options['confirm']->value ?? false) !== true) { throw new \LogicException('Review the game quote and set confirm to true before this irreversible action.'); } + $request = Controller::prepare((string) $interaction->id, (string) $interaction->member->id, $action, $payload, $revision); + } catch (\Throwable $error) { + return $interaction->respondWithMessage(self::message($error->getMessage()), true); + } + // Defer ephemerally before enqueuing. Failed acknowledgement must produce + // no economic request; failed edit leaves the durable request recoverable. + return $interaction->acknowledgeWithResponse(true)->then(function () use ($interaction, $request): PromiseInterface { + try { + $promise = $this->gateway->submit($request); + } catch (\Throwable $error) { + return $interaction->updateOriginalResponse(self::message(self::render(['status' => 'pending', 'request_id' => $request['request_id']], $request))); + } + return $promise->then( + fn (array $outcome): PromiseInterface => $interaction->updateOriginalResponse(self::message(self::render($outcome, $request))), + fn (\Throwable $error): PromiseInterface => $interaction->updateOriginalResponse(self::message(self::render(['status' => 'pending', 'request_id' => $request['request_id']], $request))) + ); + }); + } + + private static function message(string $content): MessageBuilder + { + // Bound Unicode codepoints without splitting UTF-8. 900 supplementary + // codepoints are at most 1800 UTF-16 units for Discord's 2000 limit. + if (!preg_match('/^.{0,900}/us', $content, $match)) { $match = ['Request remains unresolved.']; } + return MessageBuilder::new()->setContent($match[0])->setAllowedMentions(['parse' => []]); + } + + private static function render(array $outcome, array $request): string + { + $id = (string) ($outcome['request_id'] ?? 'unknown'); + $status = $outcome['status'] ?? 'pending'; + if ($status === 'pending') { return 'Request '.$id.' is pending. No completed trade is confirmed. Do not submit another economic operation. Use trade.status with payload '.json_encode(['operation_request_id' => $request['request_id'], 'operation_request_digest' => $request['request_digest']], JSON_THROW_ON_ERROR); } + if ($status === 'rejected') { return 'Game rejected request '.$id.'. '.json_encode($outcome['data'] ?? $outcome['reason'] ?? [], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } + if ($status !== 'committed') { return 'Request '.$id.' remains unresolved.'; } + if ($request['action'] === 'insurance.inspect') { + try { $data = Controller::insuranceProjection($outcome['data'] ?? []); } + catch (\Throwable $error) { return 'Insurance inspection completed, but its projection is invalid. No eligibility or claim is confirmed.'; } + return 'Insurance returns: game-reported pending or eligible state. Pending returns remain locked through one additional full round closure. No Discord claim action is available. Expiration: to be implemented. '.json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } + if ($request['action'] === 'trade.status') { + $operationStatus = $outcome['data']['operation_status'] ?? null; + if (!in_array($operationStatus, ['pending', 'committed', 'rejected', 'not_found'], true)) { return 'Status query completed, but the original operation remains unresolved.'; } + if (($operationStatus === 'pending' || $operationStatus === 'not_found') && ($outcome['data']['operation_receipt'] ?? null) !== null) { return 'Status query completed, but its original receipt binding is unresolved.'; } + if ($operationStatus === 'pending' || $operationStatus === 'not_found') { return 'Status query completed. Original operation is '.$operationStatus.'. No completed trade is confirmed. Do not issue another economic operation; retain its original request for recovery.'; } + $receipt = $outcome['data']['operation_receipt'] ?? null; + if (!is_array($receipt) || ($receipt['request_id'] ?? null) !== $request['payload']['operation_request_id'] || ($receipt['request_digest'] ?? null) !== $request['payload']['operation_request_digest'] || ($receipt['discord_id'] ?? null) !== $request['discord_id'] || ($receipt['status'] ?? null) !== $operationStatus) { return 'Status query completed, but its original receipt binding is unresolved.'; } + if (($receipt['protocol'] ?? null) !== Client::PROTOCOL || !in_array($receipt['action'] ?? null, Client::ACTIONS, true) || !is_string($receipt['reason'] ?? null) || strlen($receipt['reason']) < 1 || strlen($receipt['reason']) > 128 || !preg_match('//u', $receipt['reason']) || preg_match('/[\x{0000}-\x{001f}\x{007f}-\x{009f}]/u', $receipt['reason']) || !is_array($receipt['data'] ?? null)) { return 'Status query completed, but its original receipt schema is unresolved.'; } + return 'Original operation '.$receipt['request_id'].' is '.$operationStatus.'. '.json_encode($receipt['data'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } + return 'Game confirmed request '.$id.'. '.json_encode($outcome['data'] ?? [], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } +} diff --git a/src/Civ13/Safehouse/ReactTimer.php b/src/Civ13/Safehouse/ReactTimer.php new file mode 100644 index 00000000..2de46e5e --- /dev/null +++ b/src/Civ13/Safehouse/ReactTimer.php @@ -0,0 +1,14 @@ +addPeriodicTimer($interval, $callback); + return static fn () => $loop->cancelTimer($timer); + }; + } +} diff --git a/src/Civ13/Safehouse/Transport.php b/src/Civ13/Safehouse/Transport.php new file mode 100644 index 00000000..02203188 --- /dev/null +++ b/src/Civ13/Safehouse/Transport.php @@ -0,0 +1,10 @@ + Throws on timeout; never invents a settlement. */ + public function exchange(array $request): array; +} diff --git a/src/Civ13/Slash.php b/src/Civ13/Slash.php index 4e700d72..b2865a25 100644 --- a/src/Civ13/Slash.php +++ b/src/Civ13/Slash.php @@ -39,6 +39,7 @@ class Slash public Discord $discord; public Logger $logger; private bool $setup = false; + private ?\Civ13\Safehouse\DiscordRoute $safehouseRoute = null; /** @param Civ13 $civ13 The bot instance; its `discord` and `logger` are bound by reference. */ public function __construct(Civ13 &$civ13) @@ -54,6 +55,14 @@ public function __construct(Civ13 &$civ13) */ protected function afterConstruct(): void { + if (($this->civ13->options['safehouse_enabled'] ?? false) === true) { + $gateway = $this->civ13->options['safehouse_gateway'] ?? null; + if (!$gateway instanceof \Civ13\Safehouse\AsyncGateway || $this->civ13->civ13_guild_id === '') { + throw new \LogicException('Enabled safehouse requires an injected async gateway and configured guild'); + } + $this->safehouseRoute = new \Civ13\Safehouse\DiscordRoute($gateway, $this->civ13->civ13_guild_id, true); + $this->discord->listenCommand('safehouse', fn (Interaction $interaction): PromiseInterface => $this->safehouseRoute->handle($interaction)); + } $this->__declareListeners(); $fn = function () { $this->logger->info('Setting up Interaction commands...'); @@ -132,6 +141,14 @@ public function sendFollowUpMessage(Interaction $interaction, MessageBuilder|str private function __updateCommands(): void { $this->discord->application->commands->freshen()->then(function (GlobalCommandRepository $commands): void { + if ($this->safehouseRoute !== null) { + $schema = \Civ13\Safehouse\Controller::schema(); + if ($existing = $commands->get('name', 'safehouse')) { + $schema['id'] = $existing->id; + } + // Reconcile only this named command; never bulk overwrite. + $this->save($commands, new Command($this->discord, $schema)); + } if ($names = array_map(fn (Command $command) => $command->name, iterator_to_array($commands))) { $this->logger->debug(sprintf('[GLOBAL APPLICATION COMMAND LIST] `%s`', implode('`, `', $names))); } diff --git a/tests/safehouse/discord.php b/tests/safehouse/discord.php new file mode 100644 index 00000000..7d606262 --- /dev/null +++ b/tests/safehouse/discord.php @@ -0,0 +1,267 @@ +newInstanceWithoutConstructor(); + $fixture->member = (object) ['id' => '123']; + $fixture->data = (object) ['options' => ['action' => (object) ['value' => $action], 'payload' => (object) ['value' => $payload], 'confirm' => (object) ['value' => $confirm]]]; + if ($revision !== null) { $fixture->data->options['revision'] = (object) ['value' => $revision]; } + return $fixture; + } + public function respondWithMessage(MessageBuilder|string $builder, bool $ephemeral = false): PromiseInterface { + $this->messages[] = ['initial', $ephemeral, $builder instanceof MessageBuilder ? $builder->jsonSerialize() : $builder]; + return resolve(null); + } + public function acknowledgeWithResponse(bool $ephemeral = false): PromiseInterface { + $this->messages[] = ['defer', $ephemeral]; + return $this->failAck ? reject(new RuntimeException('Expired interaction')) : resolve(null); + } + public function updateOriginalResponse(MessageBuilder $builder): PromiseInterface { + $this->messages[] = ['edit', true, $builder->jsonSerialize()]; + return resolve(null); + } +} +$gateway = new class implements AsyncGateway { + public array $requests = []; + public array $deferreds = []; + public function submit(array $request): PromiseInterface { + $this->requests[] = $request; + $deferred = new Deferred(); $this->deferreds[] = $deferred; + return $deferred->promise(); + } +}; +$disabled = MockInteraction::fixture(); +(new DiscordRoute($gateway, '456'))->handle($disabled); +check(count($gateway->requests) === 0 && $disabled->messages[0][1], 'Disabled route sent request or leaked response'); +$route = new DiscordRoute($gateway, '456', true); +$missingPage = MockInteraction::fixture('stash.inspect', '{}'); +$route->handle($missingPage); +check(count($gateway->requests) === 0 && $missingPage->messages[0][0] === 'initial', 'Missing inspection page reached transport'); +$wrongGuild = MockInteraction::fixture(); $wrongGuild->guild_id = '999'; +$route->handle($wrongGuild); +check(count($gateway->requests) === 0, 'Wrong guild reached game'); +$unconfirmed = MockInteraction::fixture('trade.confirm', '{"confirmation_token":"game-token"}', 4); +$route->handle($unconfirmed); +check(count($gateway->requests) === 0 && str_contains($unconfirmed->messages[0][2]['content'], 'confirm'), 'Mutation omitted explicit confirmation'); +$forged = MockInteraction::fixture('trade.quote', '{"discord_id":"999"}', 4); +$route->handle($forged); +check(count($gateway->requests) === 0, 'Actor option reached game'); +$expired = MockInteraction::fixture(); $expired->failAck = true; +$route->handle($expired)->then(null, static fn () => null); +check(count($gateway->requests) === 0, 'Failed Discord acknowledgement reached game'); +$inspect = MockInteraction::fixture(); +$route->handle($inspect); +check($inspect->messages === [['defer', true]], 'Inspection did not defer ephemerally'); +check($gateway->requests[0]['discord_id'] === '123', 'Actor not bound to Discord member'); +check($gateway->requests[0]['payload'] === ['page' => 0], 'Discord inspection incompatible with native page schema'); +$gateway->deferreds[0]->resolve(['status' => 'pending', 'request_id' => '123456']); +check(str_contains($inspect->messages[1][2]['content'], 'pending') && $inspect->messages[1][2]['allowed_mentions'] === ['parse' => []], 'Pending claimed success or allowed mentions'); +check(str_contains($inspect->messages[1][2]['content'], $gateway->requests[0]['request_digest']), 'Pending omitted bound recovery digest'); +$confirmed = MockInteraction::fixture('trade.confirm', '{"confirmation_token":"game-token"}', 4, true); $confirmed->id = '123457'; +$route->handle($confirmed); +check($gateway->requests[1]['payload'] === ['confirmation_token' => 'game-token'], 'Confirmation changed opaque game token'); +$gateway->deferreds[1]->resolve(['status' => 'committed', 'request_id' => '123457', 'data' => ['nested_id' => 'child', 'fir' => true, 'condition' => 0.75]]); +check(str_contains($confirmed->messages[1][2]['content'], 'child'), 'Inspection metadata omitted'); +foreach (['trade.confirm','market.list','market.buy','market.cancel','room.manage'] as $action) { + foreach ([false, 1, 'true'] as $confirmation) { + $candidate=MockInteraction::fixture($action,'{"confirmation_token":"game-token"}',4,true); + $candidate->data->options['confirm']->value=$confirmation; + $before=count($gateway->requests);$route->handle($candidate); + check(count($gateway->requests)===$before && $candidate->messages[0][0]==='initial','Nonboolean/omitted confirmation reached game '.$action); + } +} +foreach (['unlinked_actor','account_busy','account_in_raid','item_not_owned','stale_revision','quote_expired','quote_actor_mismatch','quote_action_mismatch','listing_changed','account_recovery_required'] as $code) { + $denied=MockInteraction::fixture('trade.confirm','{"confirmation_token":"game-token"}',4,true);$denied->id='negative-'.$code; + $route->handle($denied);$before=count($gateway->requests); + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status'=>'rejected','request_id'=>$denied->id,'reason'=>$code,'data'=>['code'=>$code]]); + check(count($gateway->requests)===$before,'Rejected operation retried '.$code); + $content=$denied->messages[1][2]['content']; + check(str_contains($content,'rejected') && str_contains($content,$code) && !str_contains($content,'Game confirmed'),'Rejection falsely acknowledged '.$code); +} +$nested=MockInteraction::fixture('stash.detail','{"item_id":"root-1","page":0}');$nested->id='nested-preservation';$route->handle($nested); +$nodes=[['index'=>1,'id'=>'root-1','parent'=>0,'found_in_raid'=>false,'condition'=>['health'=>0,'maxhealth'=>100]],['index'=>2,'id'=>'child-2','parent'=>1,'found_in_raid'=>true,'condition'=>['health'=>17,'maxhealth'=>80]]]; +$gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status'=>'committed','request_id'=>$nested->id,'reason'=>'saved_graph','data'=>['item_id'=>'root-1','nodes'=>$nodes,'next_page'=>null]]); +$content=$nested->messages[1][2]['content']; +check(str_contains($content,json_encode($nodes,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR)),'Distinct nested identity/FIR/zero condition changed'); +$unicode = MockInteraction::fixture(); $unicode->id = 'unicode'; +$route->handle($unicode); +$gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => 'committed', 'request_id' => 'unicode', 'data' => ['name' => str_repeat('🌍', 2000)]]); +check(preg_match('//u', $unicode->messages[1][2]['content']) === 1 && strlen($unicode->messages[1][2]['content']) <= 3600, 'Bounded render split Unicode'); + +$digest = str_repeat('a', 64); +foreach (['pending', 'not_found', 'committed', 'rejected', 'wrong_actor'] as $operationStatus) { + $statusInteraction = MockInteraction::fixture('trade.status', json_encode(['operation_request_id' => 'original-operation', 'operation_request_digest' => $digest])); + $statusInteraction->id = 'status-'.$operationStatus; + $route->handle($statusInteraction); + $data = ['operation_status' => $operationStatus === 'wrong_actor' ? 'committed' : $operationStatus]; + if (in_array($operationStatus, ['committed', 'rejected', 'wrong_actor'], true)) { $data['operation_receipt'] = ['protocol' => Client::PROTOCOL, 'action' => 'stash.detail', 'reason' => 'committed', 'data' => [], 'request_id' => 'original-operation', 'request_digest' => $digest, 'discord_id' => $operationStatus === 'wrong_actor' ? '999' : '123', 'status' => $data['operation_status']]; } + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => 'committed', 'reason' => 'committed', 'data' => $data]); + $content = $statusInteraction->messages[1][2]['content']; + check(!str_contains($content, 'Game confirmed request'), 'Status query misreported original success'); + check(str_contains($content, $operationStatus === 'wrong_actor' ? 'unresolved' : $operationStatus), 'Original operation status omitted'); +} +$validReceipt = ['protocol'=>Client::PROTOCOL,'action'=>'link.complete','request_id'=>'original-operation','request_digest'=>$digest,'discord_id'=>'123','status'=>'committed','reason'=>'linked','data'=>[]]; +foreach (Client::ACTIONS as $originalAction) { + $receipt=$validReceipt; $receipt['action']=$originalAction; + $interaction=MockInteraction::fixture('trade.status',json_encode(['operation_request_id'=>'original-operation','operation_request_digest'=>$digest])); $route->handle($interaction); + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status'=>'committed','data'=>['operation_status'=>'committed','operation_receipt'=>$receipt]]); + check(str_contains($interaction->messages[1][2]['content'],'Original operation original-operation is committed'),'Supported non-economic original receipt rejected '.$originalAction); +} +foreach (['protocol'=>null,'action'=>'unknown.action','reason'=>null,'data'=>null,'status'=>'rejected','request_digest'=>str_repeat('b',64),'request_id'=>'other','discord_id'=>'999'] as $field=>$invalid) { + foreach (['missing','invalid'] as $kind) { + $receipt=$validReceipt; if ($kind==='missing') { unset($receipt[$field]); } else { $receipt[$field]=$invalid; } + $interaction=MockInteraction::fixture('trade.status',json_encode(['operation_request_id'=>'original-operation','operation_request_digest'=>$digest])); $route->handle($interaction); + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status'=>'committed','data'=>['operation_status'=>'committed','operation_receipt'=>$receipt]]); + check(str_contains($interaction->messages[1][2]['content'],'unresolved'),'Malformed original receipt claimed completion '.$field.'/'.$kind); + } +} +foreach (['',str_repeat('x',129),"bad\nreason","bad\u{0085}reason","bad\u{009f}reason",str_repeat("é",65),[],1,true] as $reason) { + $receipt=$validReceipt; $receipt['reason']=$reason; + $interaction=MockInteraction::fixture('trade.status',json_encode(['operation_request_id'=>'original-operation','operation_request_digest'=>$digest])); $route->handle($interaction); + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status'=>'committed','data'=>['operation_status'=>'committed','operation_receipt'=>$receipt]]); + check(str_contains($interaction->messages[1][2]['content'],'unresolved'),'Invalid reason accepted'); +} +foreach ([str_repeat('x',128),str_repeat("é",64)] as $reason) { + $receipt=$validReceipt; $receipt['reason']=$reason; + $interaction=MockInteraction::fixture('trade.status',json_encode(['operation_request_id'=>'original-operation','operation_request_digest'=>$digest])); $route->handle($interaction); + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status'=>'committed','data'=>['operation_status'=>'committed','operation_receipt'=>$receipt]]); + check(str_contains($interaction->messages[1][2]['content'],'Original operation original-operation is committed'),'Exact reason byte bound rejected'); +} +foreach (['pending','not_found'] as $state) { + $interaction=MockInteraction::fixture('trade.status',json_encode(['operation_request_id'=>'original-operation','operation_request_digest'=>$digest])); $route->handle($interaction); + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status'=>'committed','data'=>['operation_status'=>$state,'operation_receipt'=>$validReceipt]]); + check(str_contains($interaction->messages[1][2]['content'],'unresolved'),'Nonterminal status accepted attached receipt'); +} +foreach ([['stash.detail', '{"item_id":"native-root","page":31}', null], ['market.browse', '{"page":15}', null], ['room.quote', '{"operation":"upgrade","item_ids":["native-part"]}', 4]] as [$action, $payload, $revision]) { + $observation = MockInteraction::fixture($action, $payload, $revision); $observation->id = 'addition-'.str_replace('.', '-', $action); + $route->handle($observation); + check(end($gateway->requests)['action'] === $action && $observation->messages[0] === ['defer', true], 'Additive action not routed ephemerally'); + $data = ['nodes' => [['index' => 1, 'id' => 'native-root', 'parent' => 0, 'found_in_raid' => true, 'condition' => ['health' => 20, 'maxhealth' => 100], 'material_key' => null, 'amount' => null, 'ammo_refs' => []]], 'next_page' => null]; + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => 'committed', 'reason' => 'committed', 'data' => $data]); + check(str_contains($observation->messages[1][2]['content'], 'found_in_raid') && str_contains($observation->messages[1][2]['content'], 'maxhealth'), 'Authoritative node summary omitted'); +} +$unconfirmedRoom = MockInteraction::fixture('room.manage', '{"confirmation_token":"native-room-token"}', 4); +$countBefore = count($gateway->requests); $route->handle($unconfirmedRoom); +check(count($gateway->requests) === $countBefore, 'Room mutation bypassed explicit confirmation'); + +$insuranceData = ['account_revision' => 4, 'mail_revision' => 9, 'completed_generation' => 5, 'expiration' => 'to be implemented', 'entries' => [['item_id' => 'return_1', 'type' => null, 'name' => null, 'condition' => ['health' => null, 'maxhealth' => null], 'found_in_raid' => null, 'returned_generation' => 5, 'eligible_generation' => 6, 'state' => 'pending']], 'next_cursor' => 'opaque.next']; +foreach (['pending', 'eligible', 'invalid', 'stale_cursor'] as $case) { + $inspection = MockInteraction::fixture('insurance.inspect', '{"cursor":null}'); $inspection->id = 'insurance-'.$case; + $route->handle($inspection); + $data = $insuranceData; + if ($case === 'eligible') { $data['entries'][0]['state'] = 'eligible'; } + if ($case === 'invalid') { $data['entries'][0]['eligible_generation'] = 16777216; } + $gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => $case === 'stale_cursor' ? 'rejected' : 'committed', 'data' => $case === 'stale_cursor' ? ['code' => 'stale_cursor'] : $data]); + $content = $inspection->messages[1][2]['content']; + check(str_contains($content, $case === 'invalid' ? 'invalid' : $case), 'Insurance state/rejection omitted'); + if ($case === 'pending' || $case === 'eligible') { check(str_contains($content, 'to be implemented') && str_contains($content, 'No Discord claim'), 'Insurance expiration or claim boundary misleading'); } + check($inspection->messages[0] === ['defer', true], 'Insurance inspection not ephemeral'); +} + +// Construct only the Slash adapter over a reflection-created inert Civ13 object. +// No Civ13/Discord constructor, token, init event or remote command save runs. +require __DIR__.'/../../src/Civ13/Civ13.php'; +require __DIR__.'/../../src/Civ13/Slash.php'; +// Exercise the combined production options contract without constructing the bot. +// This catches either side of the main/safehouse defaults merge being dropped. +$optionsBot = (new ReflectionClass(\Civ13\Civ13::class))->newInstanceWithoutConstructor(); +$resolveOptions = new ReflectionMethod(\Civ13\Civ13::class, 'resolveOptions'); +$optionsBase = [ + 'loop' => new \React\EventLoop\StreamSelectLoop(), + 'browser' => (new ReflectionClass(\React\Http\Browser::class))->newInstanceWithoutConstructor(), + 'filesystem' => (new ReflectionClass(\React\Filesystem\Fallback\Adapter::class))->newInstanceWithoutConstructor(), + 'logger' => new \Monolog\Logger('offline-options', [new \Monolog\Handler\NullHandler()]), +]; +$options = $optionsBase; +$resolved = $resolveOptions->invokeArgs($optionsBot, [&$options]); +check($resolved['safehouse_enabled'] === false && $resolved['safehouse_gateway'] === null, 'Safehouse defaults activated integration'); +check($resolved['restart_mode'] === 'legacy' && $resolved['discover_external_ip'] === true, 'Main startup defaults lost'); +check($resolved['launcher_directory'] === [] && $resolved['launcher_directory_status_targets'] === [], 'Directory defaults activated a status inventory'); +$options = $optionsBase + ['safehouse_enabled' => true, 'safehouse_gateway' => $gateway, 'restart_mode' => 'supervised', 'discover_external_ip' => false, 'launcher_directory' => ['civ13.com' => []]]; +$resolved = $resolveOptions->invokeArgs($optionsBot, [&$options]); +check($resolved['safehouse_gateway'] === $gateway && $resolved['restart_mode'] === 'supervised' && $resolved['discover_external_ip'] === false, 'Injected safehouse options conflict with portable startup'); +check($resolved['launcher_directory'] === ['civ13.com' => []] && $resolved['launcher_directory_status_targets'] === [], 'Safehouse options dropped the independent directory configuration'); +class MockDiscord extends \Discord\Discord +{ + public array $bound = []; + public function listenCommand($names, ?callable $callback = null, ?callable $autocomplete_callback = null): \Discord\Helpers\RegisteredCommand { + $name = is_array($names) ? $names[0] : $names; + $this->bound[$name] = $callback; + return new \Discord\Helpers\RegisteredCommand($this, $name, $callback, $autocomplete_callback); + } +} +$bareBot = function (bool $enabled) use ($gateway): \Civ13\Civ13 { + $bot = (new ReflectionClass(\Civ13\Civ13::class))->newInstanceWithoutConstructor(); + $bot->discord = (new ReflectionClass(MockDiscord::class))->newInstanceWithoutConstructor(); + $bot->logger = new \Monolog\Logger('offline-safehouse'); + $bot->ready = false; + $bot->civ13_guild_id = '456'; + $bot->options = ['safehouse_enabled' => $enabled, 'safehouse_gateway' => $gateway]; + return $bot; +}; +$disabledBot = $bareBot(false); new \Civ13\Slash($disabledBot); +check(!isset($disabledBot->discord->bound['safehouse']) && isset($disabledBot->discord->bound['ping']), 'Disabled Slash route changed existing command'); +$enabledBot = $bareBot(true); new \Civ13\Slash($enabledBot); +check(isset($enabledBot->discord->bound['safehouse']) && isset($enabledBot->discord->bound['ping']), 'Enabled Slash route missing or replaced existing command'); +$boundInteraction = MockInteraction::fixture(); $boundInteraction->id = '654321'; +($enabledBot->discord->bound['safehouse'])($boundInteraction); +check(end($gateway->requests)['request_id'] === '654321', 'Slash callback did not use actual safehouse route'); +$gateway->deferreds[array_key_last($gateway->deferreds)]->resolve(['status' => 'rejected', 'request_id' => '654321', 'reason' => 'unlinked']); +check(str_contains($boundInteraction->messages[1][2]['content'], 'unlinked'), 'Authoritative unlinked rejection omitted'); + +// Fake timer driver checks finite asynchronous polling and cancellation. +$directory = sys_get_temp_dir().'/civ13-discord-safehouse-'.bin2hex(random_bytes(8)); +mkdir($directory, 0700); +$ticks = []; $cancelled = []; +$repeat = function (float $interval, callable $tick) use (&$ticks, &$cancelled): Closure { + $index = count($ticks); $ticks[$index] = $tick; + return function () use ($index, &$cancelled): void { $cancelled[$index] = true; }; +}; +// Exercise the installed React loop's real addPeriodicTimer/cancelTimer contract +// in this isolated test process, with no shared bot loop or external effects. +$loop = new \React\EventLoop\StreamSelectLoop(); +$realTicks = 0; $cancelRealTimer = null; +$cancelRealTimer = \Civ13\Safehouse\ReactTimer::repeat($loop)(0.001, function () use (&$realTicks, &$cancelRealTimer): void { + if (++$realTicks === 3) { $cancelRealTimer(); } +}); +$loop->run(); +check($realTicks === 3, 'Actual React timer cancellation contract failed'); +try { + $spool = new AsyncSpool($directory, str_repeat('synthetic-', 4), true); + $driver = new AsyncDriver($spool, $repeat, 2, 1); + $request = Client::envelope('timer-1', '123', 'stash.inspect', [], null); + $outcome = null; + $promise = $driver->submit($request); + check($driver->submit($request) === $promise && count($ticks) === 1, 'Duplicate created second timer'); + try { $driver->submit(array_merge($request, ['discord_id' => '999'])); throw new RuntimeException('Active collision accepted'); } catch (LogicException $e) {} + try { $driver->submit(Client::envelope('capacity', '123', 'stash.inspect', [], null)); throw new LogicException('Active capacity accepted'); } catch (RuntimeException $e) { check($e->getMessage() === 'Async request capacity reached', 'Wrong capacity rejection'); } + $promise->then(function ($value) use (&$outcome): void { $outcome = $value; }); + $ticks[0](); check($outcome === null, 'Missing reply settled early'); + $ticks[0](); check($outcome['status'] === 'pending' && $cancelled[0], 'Timeout did not cancel/preserve pending'); + $request2 = Client::envelope('timer-2', '123', 'stash.inspect', [], null); + $driver->submit($request2)->then(function ($value) use (&$outcome): void { $outcome = $value; }); + $response = array_merge($request2, ['status' => 'rejected', 'reason' => 'in_raid', 'data' => ['code' => 'in_raid']]); + $id = hash('sha256', 'timer-2'); + file_put_contents($directory.'/'.$id.'.reply', json_encode(AsyncSpool::seal(json_encode($response), 'reply', time(), time() + 120, str_repeat('synthetic-', 4)))); + $ticks[1](); check($outcome['status'] === 'rejected' && $cancelled[1], 'Game rejection not terminal'); + echo "PASS actual DiscordPHP interaction signatures, disabled/guild/actor/confirmation/acknowledgement gates, ephemeral no-mention outcomes, bounded async timers\n"; +} finally { foreach (glob($directory.'/*') as $file) { unlink($file); } rmdir($directory); } diff --git a/tests/safehouse/economic-contract.json b/tests/safehouse/economic-contract.json new file mode 100644 index 00000000..0f5e79e3 --- /dev/null +++ b/tests/safehouse/economic-contract.json @@ -0,0 +1,141 @@ +{ + "contract": "civ13.safehouse.economics.v1", + "acceptance": "dto-agreed-native-integration-unverified", + "request_protocol": "civ13.safehouse.v1", + "ipc_protocol": "civ13.safehouse.ipc.v1", + "quote_action": "trade.quote", + "quote_variants": [ + { + "sale_kind": "server_buyback", + "selector": "item_ids", + "confirm_action": "trade.confirm" + }, + { + "sale_kind": "player_listing", + "selector": "item_ids", + "confirm_action": "market.list" + }, + { + "sale_kind": "player_purchase", + "selector": "listing_id", + "confirm_action": "market.buy" + }, + { + "sale_kind": "listing_cancel", + "selector": "listing_id", + "confirm_action": "market.cancel" + } + ], + "quote_required_fields": [ + "quote_id", + "confirmation_token", + "confirm_action", + "expected_account_revision", + "expires_at", + "item_ids" + ], + "price_fields": [ + "currency_id", + "amount" + ], + "price_authority": "approved-game-tables-only-no-client-price", + "confirmation_payload_fields": [ + "confirmation_token" + ], + "confirmation_account_revision": "required-exact-quoted-actor-revision", + "confirmation_ui_option": "confirm=true", + "quote_max_age_seconds": 120, + "listing_confirmation_fields": [ + "listing_id", + "listing_revision" + ], + "immutable_operation_identity": [ + "request_id", + "discord_id", + "request_digest", + "exact_authenticated_request_bytes" + ], + "status_action": "trade.status", + "status_payload_fields": [ + "operation_request_id", + "operation_request_digest" + ], + "status_result_fields": [ + "operation_status", + "operation_receipt" + ], + "operation_status_values": [ + "pending", + "committed", + "rejected", + "not_found" + ], + "business_rejection_codes": [ + "invalid_payload", + "unlinked", + "busy", + "in_raid", + "stale_revision", + "item_not_owned", + "item_ineligible", + "invalid_graph", + "pricing_unavailable", + "quote_expired", + "token_invalid", + "token_used", + "action_mismatch", + "listing_unavailable", + "self_purchase", + "insufficient_funds", + "capacity_exceeded", + "request_conflict" + ], + "pending_code": "recovery_required", + "game_dependencies": [ + "approved-price-table-identifiers", + "wallet-unit-and-safe-integer-bounds", + "native-atomic-escrow-and-shared-receipt", + "quote-and-listing-revision-rules", + "native-handler-DTO-acceptance" + ], + "currency_id": "civ13.game_credits.v1", + "table_id": "modern_safehouse_prices_v1", + "quote_expires_at_type": "canonical-decimal-unix-seconds-string", + "account_revision_max": 1000000, + "not_found_receipt": "null-or-absent", + "decoded_body_max_bytes": 8192, + "wire_max_bytes": 12000, + "spool_file_max_bytes": 12001, + "additive_actions": [ + { + "payload_fields": [ + "item_id", + "page" + ], + "nodes_per_page": 16, + "action": "stash.detail", + "outer_revision": "nullable", + "page_max": 31 + }, + { + "payload_fields": [ + "page" + ], + "listings_per_page": 8, + "action": "market.browse", + "outer_revision": "nullable", + "page_max": 15 + }, + { + "payload_fields": [ + "item_ids", + "operation" + ], + "confirm_action": "room.manage", + "operation": "upgrade", + "action": "room.quote", + "atomic_receipt": "single-account-snapshot", + "outer_revision": "required0..1000000" + } + ] +} \ No newline at end of file diff --git a/tests/safehouse/economics-driver-test.php b/tests/safehouse/economics-driver-test.php new file mode 100644 index 00000000..59716564 --- /dev/null +++ b/tests/safehouse/economics-driver-test.php @@ -0,0 +1,53 @@ + true, 'key' => $key, 'clock' => 1000])); +function invoke(array $arguments, bool $success = true): string { + global $directory, $manifest; + $pipes = []; $process = proc_open(array_merge([PHP_BINARY, __DIR__.'/economics-driver.php', $directory, $manifest], $arguments), [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + fclose($pipes[0]); $output = stream_get_contents($pipes[1]); $error = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); + $exit = proc_close($process); if (($exit === 0) !== $success) { throw new RuntimeException('Driver result mismatch '.$error); } return trim($output); +} +function reply(string $id, array $data): void { + global $directory, $key; + $envelope = json_decode(file_get_contents($directory.'/'.$id.'.request'), true); + $body = json_decode(base64_decode($envelope['body']), true); + $body['status'] = 'committed'; $body['reason'] = 'committed'; $body['data'] = $data; + file_put_contents($directory.'/'.$id.'.reply', json_encode(AsyncSpool::seal(json_encode($body), 'reply', 1000, 1120, $key))); +} +try { + $inspect = invoke(['inspect', 'inspect-seller', '123']); + $inspectBody = json_decode(base64_decode(json_decode(file_get_contents($directory.'/'.$inspect.'.request'), true)['body']), true); + if ($inspectBody['payload'] !== ['page' => 0]) { throw new RuntimeException('Native inspection page missing'); } + reply($inspect, ['revision' => 7]); + invoke(['browse', 'browse-market', $inspect, '15']); invoke(['detail', 'detail-root', $inspect, 'native-root', '31']); + invoke(['browse', 'invalid-page', $inspect, '16'], false); invoke(['detail', 'invalid-detail-page', $inspect, 'native-root', '32'], false); + $roomQuote = invoke(['room-quote', 'room-quote', $inspect, '["native-part"]']); + $roomData = ['confirm_action' => 'room.manage', 'confirmation_token' => 'native-room-token', 'expected_account_revision' => 7, 'expires_at' => '1120']; reply($roomQuote, $roomData); + invoke(['confirm', 'room-manage', $roomQuote]); + $roomData['confirm_action'] = 'trade.confirm'; reply($roomQuote, $roomData); invoke(['confirm', 'wrong-room-action', $roomQuote], false); + foreach (['server_buyback' => 'trade.confirm', 'player_listing' => 'market.list', 'player_purchase' => 'market.buy', 'listing_cancel' => 'market.cancel'] as $kind => $action) { + $selection = in_array($kind, ['server_buyback', 'player_listing'], true) ? '["native-root"]' : '"native-listing"'; + $quote = invoke(['quote', 'quote-'.$kind, $inspect, $kind, $selection]); + $data = ['confirm_action' => $action, 'confirmation_token' => 'native-token-'.$kind, 'expected_account_revision' => 7, 'expires_at' => '1120']; reply($quote, $data); + $confirmation = invoke(['confirm', 'confirm-'.$kind, $quote]); + $bytes = file_get_contents($directory.'/'.$confirmation.'.request'); + if (invoke(['confirm', 'confirm-'.$kind, $quote]) !== $confirmation || file_get_contents($directory.'/'.$confirmation.'.request') !== $bytes) { throw new RuntimeException('Immutable confirmation changed'); } + $body = json_decode(base64_decode(json_decode($bytes, true)['body']), true); + if ($body['action'] !== $action || $body['expected_account_revision'] !== 7 || $body['payload'] !== ['confirmation_token' => $data['confirmation_token']]) { throw new RuntimeException('Quote authority replaced'); } + $data['confirmation_token'] = 'changed'; reply($quote, $data); invoke(['confirm', 'confirm-'.$kind, $quote], false); + $data['expires_at'] = '999'; reply($quote, $data); invoke(['confirm', 'expired-'.$kind, $quote], false); + $data['expires_at'] = 1120; reply($quote, $data); invoke(['confirm', 'numeric-'.$kind, $quote], false); + $data['expires_at'] = '1120'; reply($quote, $data); + file_put_contents($directory.'/'.$quote.'.reply.pending', 'incomplete'); invoke(['confirm', 'pending-'.$kind, $quote], false); unlink($directory.'/'.$quote.'.reply.pending'); + $status = invoke(['recover', 'status-'.$kind, $confirmation]); + $body = json_decode(base64_decode(json_decode(file_get_contents($directory.'/'.$status.'.request'), true)['body']), true); + if ($body['action'] !== 'trade.status' || $body['payload']['operation_request_id'] !== 'confirm-'.$kind) { throw new RuntimeException('Recovery reissued trade'); } + } + echo "PASS synthetic economics driver quote-derived actions/tokens/revisions, immutable replay, token-conflict/expiry/type/pending negatives and status recovery\n"; +} finally { foreach (glob($directory.'/*') as $path) { unlink($path); } rmdir($directory); } diff --git a/tests/safehouse/economics-driver.php b/tests/safehouse/economics-driver.php new file mode 100644 index 00000000..9eab992d --- /dev/null +++ b/tests/safehouse/economics-driver.php @@ -0,0 +1,65 @@ += 0, 'Synthetic integer clock required'); +$spool = new AsyncSpool($directory, $manifest['key'], true, 1000, static fn (): int => $now); +$boundReply = static function (string $key) use ($spool): array { + $reply = $spool->poll($key); + required(($reply['status'] ?? null) === 'committed', 'Authenticated committed source reply required'); + return $reply; +}; +switch ($command) { + case 'poll': + echo json_encode($spool->poll($requestId), JSON_THROW_ON_ERROR)."\n"; exit; + case 'recover': + echo $spool->recover($argv[5] ?? '', $requestId)."\n"; exit; + case 'inspect': + $request = Client::envelope($requestId, $argv[5] ?? '', 'stash.inspect', ['page' => 0], null); break; + case 'browse': + case 'detail': + $source = $boundReply($argv[5] ?? ''); + required(($source['action'] ?? null) === 'stash.inspect', 'Observation actor must come from inspection'); + $pageText = $argv[$command === 'detail' ? 7 : 6] ?? '0'; + required(ctype_digit($pageText) && (int)$pageText <= ($command === 'detail' ? 31 : 15), 'Invalid observation page'); + $payload = ['page' => (int)$pageText]; + if ($command === 'detail') { required(preg_match('/^[A-Za-z0-9_-]{1,80}$/D', $argv[6] ?? '') === 1, 'Invalid root ID'); $payload['item_id'] = $argv[6]; } + $request = Client::envelope($requestId, $source['discord_id'], $command === 'detail' ? 'stash.detail' : 'market.browse', $payload, null); break; + case 'quote': + case 'room-quote': + $source = $boundReply($argv[5] ?? ''); + required(($source['action'] ?? null) === 'stash.inspect', 'Quote revision must come from inspection'); + $revision = $source['data']['revision'] ?? null; + required(is_int($revision) && $revision >= 0 && $revision <= 1000000, 'Invalid authoritative revision'); + if ($command === 'room-quote') { + $selection = json_decode($argv[6] ?? '', true, 32, JSON_THROW_ON_ERROR); + required(is_array($selection) && array_is_list($selection) && count($selection) >= 1 && count($selection) <= 16 && count(array_unique($selection, SORT_REGULAR)) === count($selection), 'Invalid room selectors'); + foreach ($selection as $id) { required(is_string($id) && preg_match('/^[A-Za-z0-9_-]{1,80}$/D', $id) === 1, 'Invalid room root ID'); } + $request = Client::envelope($requestId, $source['discord_id'], 'room.quote', ['operation' => 'upgrade', 'item_ids' => $selection], $revision); break; + } + $kind = $argv[6] ?? ''; + $selectors = ['server_buyback' => 'item_ids', 'player_listing' => 'item_ids', 'player_purchase' => 'listing_id', 'listing_cancel' => 'listing_id']; + required(isset($selectors[$kind]), 'Unsupported sale kind'); + $selection = json_decode($argv[7] ?? '', true, 32, JSON_THROW_ON_ERROR); + required($selectors[$kind] === 'item_ids' ? is_array($selection) && array_is_list($selection) && $selection !== [] : is_string($selection) && $selection !== '', 'Invalid selector'); + $request = Client::envelope($requestId, $source['discord_id'], 'trade.quote', ['sale_kind' => $kind, $selectors[$kind] => $selection], $revision); break; + case 'confirm': + $quote = $boundReply($argv[5] ?? ''); + required(in_array($quote['action'] ?? null, ['trade.quote', 'room.quote'], true), 'Confirmation requires bound quote reply'); + $data = $quote['data'] ?? []; + required($quote['action'] === 'room.quote' ? ($data['confirm_action'] ?? null) === 'room.manage' : in_array($data['confirm_action'] ?? null, ['trade.confirm', 'market.list', 'market.buy', 'market.cancel'], true), 'Invalid quote action'); + required(is_string($data['confirmation_token'] ?? null) && $data['confirmation_token'] !== '', 'Missing authoritative token'); + required(is_int($data['expected_account_revision'] ?? null) && $data['expected_account_revision'] >= 0 && $data['expected_account_revision'] <= 1000000, 'Invalid quoted revision'); + required(is_string($data['expires_at'] ?? null) && preg_match('/^(0|[1-9][0-9]{0,10})$/D', $data['expires_at']) === 1 && (int)$data['expires_at'] >= $now, 'Quote expired or noncanonical expiry'); + $request = Client::envelope($requestId, $quote['discord_id'], $data['confirm_action'], ['confirmation_token' => $data['confirmation_token']], $data['expected_account_revision']); break; + default: throw new InvalidArgumentException('Unknown synthetic command'); +} +// enqueue retains exact immutable bytes, rejects collisions and never overwrites. +echo $spool->enqueue($request)."\n"; diff --git a/tests/safehouse/native-compatibility.php b/tests/safehouse/native-compatibility.php new file mode 100644 index 00000000..4f4309c9 --- /dev/null +++ b/tests/safehouse/native-compatibility.php @@ -0,0 +1,46 @@ + $fileKey) { + verify(hash('sha256', $requestId) === $fileKey, 'Native filename digest mismatch'); + $original = file_get_contents($source.'/before-restart/'.$fileKey.'.request'); + verify($original === file_get_contents($source.'/after-restart/'.$fileKey.'.request'), 'Recovery changed immutable request'); + $requestEnvelope = json_decode($original, true, 32, JSON_THROW_ON_ERROR); + verify($requestEnvelope['issued_at'] === 1000 && $requestEnvelope['expires_at'] === 1120, 'Recovery re-signed original timewindow'); + foreach (['request', 'reply', 'reply.pending'] as $suffix) { copy($source.'/before-restart/'.$fileKey.'.'.$suffix, $temporary.'/'.$fileKey.'.'.$suffix); } + verify($spool->poll($fileKey)['status'] === 'pending', 'Unpublished native reply accepted'); + copy($source.'/after-restart/'.$fileKey.'.reply', $temporary.'/'.$fileKey.'.reply'); + verify($spool->poll($fileKey)['status'] === 'pending', 'Complete native reply bypassed marker'); + unlink($temporary.'/'.$fileKey.'.reply.pending'); + $committed = $spool->poll($fileKey); + verify($committed['status'] === 'committed' && $committed['request_id'] === $requestId, 'Native recovery receipt incompatible'); + $replyPath = $temporary.'/'.$fileKey.'.reply'; + $replyBytes = file_get_contents($replyPath); + $replyEnvelope = json_decode($replyBytes, true, 32, JSON_THROW_ON_ERROR); + $altered = $replyEnvelope; $altered['mac'] = str_repeat('0', 64); file_put_contents($replyPath, json_encode($altered)); + rejected(fn () => $spool->poll($fileKey)); + $alteredBody = json_decode(base64_decode($replyEnvelope['body'], true), true, 32, JSON_THROW_ON_ERROR); + $alteredBody['discord_id'] = '999'; + file_put_contents($replyPath, json_encode(AsyncSpool::seal(json_encode($alteredBody), 'reply', 1000, 1120, $manifest['key']))); + rejected(fn () => $spool->poll($fileKey)); + file_put_contents($replyPath, $replyBytes); + $now = 2000; verify($spool->poll($fileKey)['status'] === 'pending', 'Expired native receipt accepted'); $now = 1000; + verify(file_get_contents($temporary.'/'.$fileKey.'.request') === $original, 'Bot changed native operation'); + echo "PASS native $requestId marker/recovery/expiry/immutable-request/MAC/actor-binding\n"; + } +} finally { + foreach (glob($temporary.'/*') as $path) { unlink($path); } rmdir($temporary); +} diff --git a/tests/safehouse/reply-worker.php b/tests/safehouse/reply-worker.php new file mode 100644 index 00000000..33f1a77c --- /dev/null +++ b/tests/safehouse/reply-worker.php @@ -0,0 +1,13 @@ +calls[] = $request; + if ($this->timeout) { throw new RuntimeException('timeout after possible commit'); } + return array_merge($request, ['status' => 'committed', 'reason' => 'committed', 'request_digest' => $this->mismatch ? 'wrong' : $request['request_digest'], 'data' => ['item_id' => 'nested-1', 'fir' => true, 'condition' => 0.75]]); + } +}; +try { + $insurance = ['account_revision' => 4, 'mail_revision' => 9, 'completed_generation' => 5, 'expiration' => 'to be implemented', 'entries' => [['item_id' => 'return_1', 'type' => '/obj/item', 'name' => null, 'condition' => ['health' => 2, 'maxhealth' => null], 'found_in_raid' => true, 'returned_generation' => 5, 'eligible_generation' => 6, 'state' => 'pending']], 'next_cursor' => 'opaque.cursor']; + check(Controller::insuranceProjection($insurance) === $insurance, 'Pending eligibility was inferred locally'); + check(Controller::prepare('insurance1', '123', 'insurance.inspect', '{"cursor":null}', null)['payload'] === ['cursor' => null], 'Initial insurance cursor changed'); + check(Controller::prepare('insurance2', '123', 'insurance.inspect', '{"cursor":"opaque.cursor"}', 4)['payload']['cursor'] === 'opaque.cursor', 'Opaque cursor changed'); + foreach (['{"cursor":""}', '{"cursor":"é"}', '{"cursor":0}', '{"cursor":null,"page":0}'] as $invalid) { rejects(fn () => Controller::prepare('insurance3', '123', 'insurance.inspect', $invalid, null)); } + rejects(fn () => Controller::prepare('insurance4', '123', 'insurance.claim', '{"item_id":"return_1"}', 4)); + foreach (['mail_revision' => 16777216, 'account_revision' => 1000001, 'expiration' => 'tomorrow', 'next_cursor' => ''] as $field => $value) { $bad = $insurance; $bad[$field] = $value; rejects(fn () => Controller::insuranceProjection($bad)); } + $bad = $insurance; $bad['entries'] = array_fill(0, 9, $insurance['entries'][0]); rejects(fn () => Controller::insuranceProjection($bad)); + foreach (['state' => 'claimable', 'found_in_raid' => 1, 'name' => str_repeat('😀', 33), 'eligible_generation' => -1, 'proof' => 'secret'] as $field => $value) { $bad = $insurance; $bad['entries'][0][$field] = $value; rejects(fn () => Controller::insuranceProjection($bad)); } + $bad = $insurance; $bad['entries'][0]['condition']['health'] = INF; rejects(fn () => Controller::insuranceProjection($bad)); + echo "PASS insurance pending projection, opaque paging, premature claim rejection and counter/catalog/condition limits\n"; + $signingFixture = json_decode(file_get_contents(__DIR__.'/signing-fixture.json'), true, 32, JSON_THROW_ON_ERROR); + check(AsyncSpool::seal($signingFixture['exact_body'], 'request', 1000, 1120, $signingFixture['synthetic_key']) === $signingFixture['envelope'], 'Independent Python HMAC fixture mismatch'); + rejects(fn () => (new Client($transport, $directory.'/disabled'))->request('i1', '123', 'stash.inspect', [], null)); + check(count($transport->calls) === 0, 'Disabled client sent request'); + $client = new Client($transport, $directory.'/journal', true); + $transport->timeout = true; + check($client->request('i1', '123', 'trade.confirm', ['confirmation_token' => 'game-token'], 4)['status'] === 'pending', 'Timeout claimed success'); + $original = $transport->calls[0]; + $transport->timeout = false; + $restarted = new Client($transport, $directory.'/journal', true); + $response = $restarted->request('i1', '123', 'trade.confirm', ['confirmation_token' => 'game-token'], 4); + check($transport->calls[1] === $original, 'Restart replay changed immutable request'); + check($response['data']['item_id'] === 'nested-1' && $response['data']['fir'] === true, 'Game metadata changed'); + $restarted->request('i1', '123', 'trade.confirm', ['confirmation_token' => 'game-token'], 4); + check(count($transport->calls) === 2, 'Terminal duplicate resent'); + rejects(fn () => $client->request('i1', '999', 'trade.confirm', ['confirmation_token' => 'game-token'], 4)); + rejects(fn () => $client->request('i1', '123', 'trade.confirm', ['confirmation_token' => 'changed'], 4)); + $transport->mismatch = true; + rejects(fn () => $client->request('i2', '123', 'stash.inspect', [], null)); + $transport->mismatch = false; + $controller = new Controller($client); + foreach (['stash.inspect', 'room.inspect'] as $inspection) { + foreach (['{}', '{"page":8}', '{"page":-1}', '{"page":"0"}', '{"page":0,"extra":1}'] as $invalidPage) { rejects(fn () => Controller::prepare('invalid-page', '123', $inspection, $invalidPage, null)); } + check(Controller::prepare('valid-page', '123', $inspection, '{"page":0}', null)['payload'] === ['page' => 0], 'Inspection page changed'); + } + foreach ([['trade.quote', '{"sale_kind":"player_purchase","item_ids":["root"]}', 1], ['trade.quote', '{"sale_kind":"server_buyback","item_ids":["root","root"]}', 1], ['trade.quote', '{"sale_kind":"server_buyback","item_ids":["root"]}', null], ['link.complete', '{"challenge_id":"id","proof":"proof","ckey":"victim"}', null], ['link.complete', '{"challenge_id":"id"}', null], ['trade.confirm', '{"confirmation_token":"token","price":1}', 1], ['trade.status', '{"operation_request_id":"id","operation_request_digest":"bad"}', null], ['stash.inspect', '{"page":0}', 1000001]] as [$action, $payload, $revision]) { + rejects(fn () => Controller::prepare('bad-contract', '123', $action, $payload, $revision)); + } + check(Controller::prepare('link-proof', '123', 'link.complete', '{"challenge_id":"game-origin","proof":"game-proof"}', null)['payload'] === ['challenge_id' => 'game-origin', 'proof' => 'game-proof'], 'Game-origin link proof altered'); + check(Controller::prepare('detail', '123', 'stash.detail', '{"item_id":"root","page":31}', null)['payload'] === ['item_id' => 'root', 'page' => 31], 'Detail schema changed'); + check(Controller::prepare('browse', '123', 'market.browse', '{"page":15}', null)['action'] === 'market.browse', 'Browse missing'); + check(Controller::prepare('room-quote', '123', 'room.quote', '{"operation":"upgrade","item_ids":["part"]}', 7)['payload'] === ['item_ids' => ['part'], 'operation' => 'upgrade'], 'Room quote mutated selectors'); + foreach ([['stash.detail', '{"item_id":"root","page":32}', null], ['market.browse', '{"page":16}', null], ['market.browse', '{"page":"1"}', null], ['room.quote', '{"operation":"upgrade","item_ids":["part"]}', null], ['room.quote', '{"operation":"downgrade","item_ids":["part"]}', 1], ['room.quote', '{"operation":"upgrade","item_ids":["part","part"]}', 1], ['room.quote', '{"operation":"upgrade","item_ids":["part"],"required_parts":5}', 1]] as [$action, $payload, $revision]) { rejects(fn () => Controller::prepare('bad-addition', '123', $action, $payload, $revision)); } + rejects(fn () => $controller->handle('i3', '123', 'trade.confirm', '{"confirmation_token":"token"}', null)); + rejects(fn () => $controller->handle('i3', '123', 'trade.quote', '{"price":1}', 4)); + check($controller->handle('i3', '123', 'stash.inspect', '{"page":1}', null)['ephemeral'], 'Private response not ephemeral'); + check(count(Controller::schema()['options'][0]['choices']) === count(Client::ACTIONS), 'Schema/router mismatch'); + $canonical = $client->request('ordered', '123', 'trade.quote', ['z' => 2, 'a' => ['q' => 2, 'b' => 1]], 4); + check($client->request('ordered', '123', 'trade.quote', ['a' => ['b' => 1, 'q' => 2], 'z' => 2], 4) === $canonical, 'Object order changed identity'); + rejects(fn () => $client->request('large', '123', 'stash.inspect', ['value' => str_repeat('x', 9000)], null)); + // Separate native PHP processes exercise the OS lock and restart after a + // committed mock-game effect followed by process death before reply storage. + $spawn = function (string $path, string $mode): array { + $pipes = []; + $process = proc_open([PHP_BINARY, __DIR__.'/worker.php', $path, $mode], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + check(is_resource($process), 'Worker did not start'); + fclose($pipes[0]); + return [$process, $pipes]; + }; + $finish = function (array $worker, int $expected): void { + [$process, $pipes] = $worker; + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + check(proc_close($process) === $expected && $error === '', 'Worker failed: '.$error.$output); + }; + foreach (['parallel', 'crash'] as $case) { mkdir($directory.'/'.$case, 0700); } + $one = $spawn($directory.'/parallel', 'normal'); + $two = $spawn($directory.'/parallel', 'normal'); + $finish($one, 0); $finish($two, 0); + check(count(file($directory.'/parallel/deliveries')) === 1, 'Concurrent terminal duplicate reached transport'); + $finish($spawn($directory.'/crash', 'crash'), 23); + $finish($spawn($directory.'/crash', 'normal'), 0); + check(count(file($directory.'/crash/deliveries')) === 2, 'Crash did not replay'); + check(count(file($directory.'/crash/settlements')) === 1, 'Mock game settled twice'); + check(file($directory.'/crash/deliveries')[0] === file($directory.'/crash/deliveries')[1], 'Crash replay changed bytes'); + mkdir($directory.'/spool', 0700); + $key = str_repeat('synthetic-test-key-', 3); + $now = 1000; + $clock = function () use (&$now): int { return $now; }; + $spool = new AsyncSpool($directory.'/spool', $key, true, 1, $clock); + $request = $original; + $fileKey = $spool->enqueue($request); + $published = file_get_contents($directory.'/spool/'.$fileKey.'.request'); + check($spool->enqueue($request) === $fileKey && file_get_contents($directory.'/spool/'.$fileKey.'.request') === $published, 'Spool replay changed bytes/signature'); + check($spool->poll($fileKey)['status'] === 'pending', 'Missing reply claimed success'); + rejects(fn () => $spool->enqueue(array_merge($request, ['discord_id' => '999']))); + try { $spool->enqueue(array_merge($request, ['request_id' => 'queue-full'])); throw new LogicException('Queue bound missing'); } catch (RuntimeException $e) { check($e->getMessage() === 'Queue capacity reached', 'Unexpected queue exception'); } + $reply = array_merge($request, ['status' => 'committed', 'reason' => 'committed', 'data' => []]); + $replyPath = $directory.'/spool/'.$fileKey.'.reply'; + $sealed = AsyncSpool::seal(json_encode($reply), 'reply', 1000, 1120, $key); + check(strlen(AsyncSpool::seal(str_repeat('x', 8192), 'reply', 1000, 1120, $key)['body']) === 10924, 'Exact byte boundary rejected'); + rejects(fn () => AsyncSpool::seal(str_repeat('x', 8193), 'reply', 1000, 1120, $key)); + rejects(fn () => AsyncSpool::seal(str_repeat('🌍', 2049), 'reply', 1000, 1120, $key)); + foreach (['reason', 'data'] as $field) { + foreach (['missing', null, 1, true] as $invalid) { + $bad = $reply; + if ($invalid === 'missing') { unset($bad[$field]); } else { $bad[$field] = $invalid; } + rejects(fn () => Client::validateReply($bad, $request)); + file_put_contents($replyPath, json_encode(AsyncSpool::seal(json_encode($bad), 'reply', 1000, 1120, $key))); + rejects(fn () => $spool->poll($fileKey)); + } + } + $bad = $reply; $bad['reason'] = []; rejects(fn () => Client::validateReply($bad, $request)); + $bad = $reply; $bad['data'] = 'scalar'; rejects(fn () => Client::validateReply($bad, $request)); + file_put_contents($replyPath, json_encode($sealed)."\n"); + check($spool->poll($fileKey)['status'] === 'committed', 'Native single-LF reply rejected'); + file_put_contents($replyPath, json_encode($sealed)."\r\n"); check($spool->poll($fileKey)['status'] === 'committed', 'Bounded historical CRLF rejected'); + $oversizedBody = base64_encode(str_repeat('x', 8193)); + $oversized = ['protocol' => AsyncSpool::IPC_PROTOCOL, 'issued_at' => 1000, 'expires_at' => 1120, 'body' => $oversizedBody, 'mac' => hash_hmac('sha256', AsyncSpool::IPC_PROTOCOL."\nreply\n1000\n1120\n".$oversizedBody, $key)]; + file_put_contents($replyPath, json_encode($oversized)); rejects(fn () => $spool->poll($fileKey)); + file_put_contents($replyPath, str_repeat('x', 12002)); + try { $spool->poll($fileKey); throw new LogicException('Oversized wire accepted'); } catch (RuntimeException $e) { check(str_contains($e->getMessage(), 'oversized'), 'Wrong file bound rejection'); } + file_put_contents($replyPath, json_encode($sealed)); + $markerPath = $replyPath.'.pending'; + file_put_contents($markerPath, ''); + check($spool->poll($fileKey)['reason'] === 'reply_write_pending', 'Empty marker allowed success'); + file_put_contents($replyPath, '{partial'); + check($spool->poll($fileKey)['status'] === 'pending', 'Partial marked reply claimed success'); + file_put_contents($markerPath, '{invalid-marker'); + check($spool->poll($fileKey)['status'] === 'pending', 'Invalid marker allowed success'); + unlink($markerPath); + try { $spool->poll($fileKey); throw new LogicException('Unmarked partial reply accepted'); } catch (JsonException $e) {} + file_put_contents($replyPath, json_encode($sealed)); + file_put_contents($markerPath, 'crash-after-complete-write'); + check($spool->poll($fileKey)['status'] === 'pending', 'Complete reply bypassed crash marker'); + check($spool->enqueue($request) === $fileKey, 'Marked reply changed immutable replay'); + check(is_file($markerPath), 'Bot removed native recovery marker'); + unlink($markerPath); + check($spool->poll($fileKey)['status'] === 'committed', 'Authenticated reply not accepted'); + $writerPipes = []; + $writer = proc_open([PHP_BINARY, __DIR__.'/reply-worker.php', $replyPath, base64_encode(json_encode($sealed))], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $writerPipes); + check(is_resource($writer), 'Reply writer unavailable'); + check(trim(fgets($writerPipes[1])) === 'partial', 'Writer partial stage missing'); + check($spool->poll($fileKey)['status'] === 'pending', 'Concurrent partial publication accepted'); + fwrite($writerPipes[0], "continue\n"); fflush($writerPipes[0]); + check(trim(fgets($writerPipes[1])) === 'complete-marked', 'Writer complete stage missing'); + check($spool->poll($fileKey)['status'] === 'pending', 'Concurrent complete marked publication accepted'); + fwrite($writerPipes[0], "continue\n"); fflush($writerPipes[0]); + check(trim(fgets($writerPipes[1])) === 'released', 'Writer release missing'); + check($spool->poll($fileKey)['status'] === 'committed', 'Released authenticated reply unavailable'); + fclose($writerPipes[0]); fclose($writerPipes[1]); + check(stream_get_contents($writerPipes[2]) === '', 'Reply worker diagnostics'); fclose($writerPipes[2]); + check(proc_close($writer) === 0, 'Reply worker failed'); + $tampered = $sealed; $tampered['mac'] = str_repeat('0', 64); + file_put_contents($replyPath, json_encode($tampered)); + rejects(fn () => $spool->poll($fileKey)); + $tampered = $sealed; $tampered['body'] = base64_encode(json_encode(array_merge($reply, ['discord_id' => '999']))); + file_put_contents($replyPath, json_encode($tampered)); + rejects(fn () => $spool->poll($fileKey)); + file_put_contents($replyPath, json_encode(AsyncSpool::seal(json_encode(array_merge($reply, ['discord_id' => '999'])), 'reply', 1000, 1120, $key))); + rejects(fn () => $spool->poll($fileKey)); + file_put_contents($replyPath, json_encode(AsyncSpool::seal(json_encode($reply), 'reply', 800, 920, $key))); + check($spool->poll($fileKey)['status'] === 'pending', 'Expired reply claimed success'); + rejects(fn () => $spool->poll('../escape')); + $now = 1300; + $recoverySpool = new AsyncSpool($directory.'/spool', $key, true, 2, $clock); + $recoveryId = $recoverySpool->recover($fileKey, 'status-after-expiry'); + $recoveryEnvelope = json_decode(file_get_contents($directory.'/spool/'.$recoveryId.'.request'), true); + $recoveryRequest = json_decode(base64_decode($recoveryEnvelope['body']), true); + check($recoveryEnvelope['issued_at'] === 1300 && $recoveryRequest['action'] === 'trade.status' && $recoveryRequest['payload']['operation_request_id'] === $request['request_id'] && $recoveryRequest['payload']['operation_request_digest'] === $request['request_digest'], 'Recovery reissued economic operation'); + check(file_get_contents($directory.'/spool/'.$fileKey.'.request') === $published, 'Recovery overwrote immutable operation'); + rejects(fn () => $recoverySpool->recover($fileKey, $request['request_id'])); + // Linux CI can create symlinks without Windows Developer Mode privileges. + if (PHP_OS_FAMILY !== 'Windows') { + check(symlink($directory.'/journal', $markerPath), 'Marker symlink fixture unavailable'); + try { $spool->poll($fileKey); throw new LogicException('Marker symlink accepted'); } catch (RuntimeException $e) { check(str_contains($e->getMessage(), 'Symlink'), 'Wrong marker symlink rejection'); } + unlink($markerPath); + unlink($replyPath); + $linkPath = $replyPath; + check(symlink($directory.'/journal', $linkPath), 'Symlink fixture unavailable'); + try { $spool->poll($fileKey); throw new LogicException('Symlink accepted'); } catch (RuntimeException $e) { check(str_contains($e->getMessage(), 'Symlink'), 'Wrong reply symlink rejection'); } + unlink($linkPath); + $requestLink = $directory.'/spool/'.str_repeat('b', 64).'.request'; + check(symlink($directory.'/journal', $requestLink), 'Request symlink fixture unavailable'); + try { $spool->poll(str_repeat('b', 64)); throw new LogicException('Request symlink accepted'); } catch (RuntimeException $e) { check(str_contains($e->getMessage(), 'Symlink'), 'Wrong symlink rejection'); } + unlink($requestLink); + } + echo "PASS bounded authenticated spool, byte-identical publication/replay, actor/body/MAC negative controls and reply expiry\n"; + echo "PASS disabled default, restart replay, terminal deduplication, actor/payload binding, response negative control, confirmation gates, private schema\n"; + echo "PASS native cross-process lock, crash after game commit, exact replay and mock game single settlement\n"; +} finally { + foreach (glob($directory.'/*') as $file) { + if (is_dir($file)) { + foreach (glob($file.'/*') as $child) { unlink($child); } + rmdir($file); + } else { unlink($file); } + } + rmdir($directory); +} diff --git a/tests/safehouse/schema.php b/tests/safehouse/schema.php new file mode 100644 index 00000000..6d003680 --- /dev/null +++ b/tests/safehouse/schema.php @@ -0,0 +1,5 @@ +directory.'/deliveries', json_encode($request)."\n", FILE_APPEND); + $receipt = $this->directory.'/receipt'; + if (!is_file($receipt)) { + file_put_contents($receipt, json_encode($request)); + file_put_contents($this->directory.'/settlements', "settled\n", FILE_APPEND); + } elseif (json_decode(file_get_contents($receipt), true) !== $request) { + throw new RuntimeException('Mock game identity mismatch'); + } + if ($this->mode === 'crash') { + exit(23); // Game committed; bot never records the reply. + } + usleep(150000); + return array_merge($request, ['status' => 'committed', 'reason' => 'committed', 'data' => []]); + } +}; +$response = (new Client($transport, $directory.'/journal', true))->request('concurrent', '123', 'trade.confirm', ['confirmation_token' => 'game-token'], 4); +if ($response['status'] !== 'committed') { exit(2); } +echo "committed\n";