Compare commits
1 Commits
main
...
ea709b6277
| Author | SHA1 | Date | |
|---|---|---|---|
| ea709b6277 |
+46
-24
@@ -22,28 +22,28 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path.
|
|||||||
| S12 | Transfer serving gates | A peer has a non-catalog, missing-sentinel, active-operation, or `local/` path request. | The serving peer declines metadata/data; covered by unit tests where timing is too small for a stable CLI race test. |
|
| S12 | Transfer serving gates | A peer has a non-catalog, missing-sentinel, active-operation, or `local/` path request. | The serving peer declines metadata/data; covered by unit tests where timing is too small for a stable CLI race test. |
|
||||||
| S13 | Exact transferred-file equality | Repeat small and large downloads, then compare every transferred regular file against its source with SHA-256 manifests. | Source and receiver manifests match exactly for each transferred file; no extra or missing files appear in the downloaded game root. |
|
| S13 | Exact transferred-file equality | Repeat small and large downloads, then compare every transferred regular file against its source with SHA-256 manifests. | Source and receiver manifests match exactly for each transferred file; no extra or missing files appear in the downloaded game root. |
|
||||||
| S14 | Large multi-peer chunked download | `fixture-alpha/alienswarm` contains a renamed RAR `.eti` larger than 100 MB. A second peer downloads it, then a third peer downloads `alienswarm` from both peers. | The third peer's downloaded files match the source by SHA-256; `download-chunk-finished` events show the large `.eti` chunks coming from both peers with byte counts balanced within one chunk. |
|
| S14 | Large multi-peer chunked download | `fixture-alpha/alienswarm` contains a renamed RAR `.eti` larger than 100 MB. A second peer downloads it, then a third peer downloads `alienswarm` from both peers. | The third peer's downloaded files match the source by SHA-256; `download-chunk-finished` events show the large `.eti` chunks coming from both peers with byte counts balanced within one chunk. |
|
||||||
| S15 | Three-way version skew | Three peers advertise the same catalog game ID. Peer A has `version.ini=20250101`, peer B has `version.ini=20250201`, and peer C has `version.ini=20250301`; each version has distinguishable file contents. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=3` and `eti_game_version=20250301`. The `got-game-files` descriptor set and transfer source are peer C's newest version only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. |
|
| S15 | Catalog-version skew | Three peers advertise the same catalog game ID. Peers A and B have stale `version.ini` values; peer C has the catalog's expected version. An empty client connects to all three and downloads the game with `install=false`. | `list-games` shows one row for the game with `peer_count=1` and the catalog `eti_game_version`. The `got-game-files` descriptor set and transfer source are peer C only; no chunks come from A or B. The receiver's `version.ini` and SHA-256 manifest match C exactly. |
|
||||||
| S16 | Latest-version fanout with stale peers present | Peer A has an older version of a game. Peers B and C both advertise the same newest version with matching file manifests; use a large file when proving chunk split. | The aggregated row still counts all ready peers, but eligible transfer peers are only B and C. Large-file chunks may split between B and C; peer A contributes no manifest majority vote and no file chunks. |
|
| S16 | Catalog-version fanout with stale peers present | Peer A has a stale version of a game. Peers B and C both advertise the catalog version with matching file manifests; use a large file when proving chunk split. | The aggregated row counts only catalog-version ready peers. Large-file chunks may split between B and C; peer A is not listed as downloadable and contributes no manifest vote or file chunks. |
|
||||||
| S17 | Latest-version conflict rejection | Peer A has an older version. Peers B and C both advertise the newest version, but their latest-version file sizes conflict. | Validation considers only the latest-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
|
| S17 | Catalog-version conflict rejection | Peer A has a stale version. Peers B and C both advertise the catalog version, but their file sizes conflict. | Validation considers only the catalog-version peers, so A cannot rescue the majority. The download fails with `download-failed`, and no committed target `version.ini` remains. |
|
||||||
| S18 | Mid-download source drop with redundancy | Client downloads a large shared game from two ready peers, then one source is killed after the download has begun. | Failed chunks are retried against the surviving source; the download finishes, no `download-failed` is emitted, and the receiver's files match the source by diff or SHA-256. |
|
| S18 | Mid-download source drop with redundancy | Client downloads a large shared game from two ready peers, then one source is killed after the download has begun. | Failed chunks are retried against the surviving source; the download finishes, no `download-failed` is emitted, and the receiver's files match the source by diff or SHA-256. |
|
||||||
| S19 | Mid-download sole-source drop | Client downloads a large game from one source, then that source is killed after the download has begun. | The download emits `download-failed`; no committed target `version.ini` remains; any partial payload is not advertised as ready; active operation state clears so a retry is possible. |
|
| S19 | Mid-download sole-source drop | Client downloads a large game from one source, then that source is killed after the download has begun. | The download emits `download-failed`; no committed target `version.ini` remains; any partial payload is not advertised as ready; active operation state clears so a retry is possible. |
|
||||||
| S20 | Receiver write failure | Client downloads a large game into a constrained `/games` filesystem. | The download fails deterministically, no committed `version.ini` is advertised, and active operation state clears so the peer can retry later. |
|
| S20 | Receiver write failure | Client downloads a large game into a constrained `/games` filesystem. | The download fails deterministically, no committed `version.ini` is advertised, and active operation state clears so the peer can retry later. |
|
||||||
| S21 | Add-game propagation | Two connected peers are running; one peer gains a new catalog game root through a completed download or an external drop. | The other peer receives a library update without reconnecting, and `list-games` shows the new remote game under the existing peer. |
|
| S21 | Add-game propagation | Two connected peers are running; one peer gains a new catalog game root through a completed download or an external drop. | The other peer receives a library update without reconnecting, and `list-games` shows the new remote game under the existing peer. |
|
||||||
| S22 | Remove-game propagation | Two connected peers are running; one peer loses a previously advertised game root. | The other peer receives a library update without dropping the peer, and `list-games` no longer shows that remote game. |
|
| S22 | Remove-game propagation | Two connected peers are running; one peer loses a previously advertised game root. | The other peer receives a library update without dropping the peer, and `list-games` no longer shows that remote game. |
|
||||||
| S23 | Version bump propagation | Two connected peers are running; one peer's ready game root gets a newer `version.ini`. | The other peer receives a library update without reconnecting, and the aggregated row reflects the newer `eti_game_version`. |
|
| S23 | Version bump propagation | Two connected peers are running; one peer's ready game root starts with a stale `version.ini`, then changes to the catalog version. | The other peer receives a library update without reconnecting; the stale row is absent before the change, then the catalog-version game appears as downloadable. |
|
||||||
| S24 | Two clients pull from one source | Two empty clients connect to the same source and download the same large game concurrently. | Both downloads finish, both receivers match the source by diff or SHA-256, and the source remains responsive. |
|
| S24 | Two clients pull from one source | Two empty clients connect to the same source and download the same large game concurrently. | Both downloads finish, both receivers match the source by diff or SHA-256, and the source remains responsive. |
|
||||||
| S25 | One client downloads two games concurrently | One client connected to a source issues two different `download` commands without waiting for the first to finish. | Both operations may run in parallel; both eventually finish, each game reaches the requested install state, and each transferred root matches its source. |
|
| S25 | One client downloads two games concurrently | One client connected to a source issues two different `download` commands without waiting for the first to finish. | Both operations may run in parallel; both eventually finish, each game reaches the requested install state, and each transferred root matches its source. |
|
||||||
| S26 | Same-game duplicate download rejection | A client starts downloading a game, then issues a second `download` command for the same game while the first operation is active. | The second request is rejected deterministically as an operation-in-progress condition; the first download is not corrupted and still reaches its documented final state. |
|
| S26 | Same-game duplicate download rejection | A client starts downloading a game, then issues a second `download` command for the same game while the first operation is active. | The second request is rejected deterministically as an operation-in-progress condition; the first download is not corrupted and still reaches its documented final state. |
|
||||||
| S27 | Self-connect rejection | A peer sends `connect` to its own advertised listener address. | The command fails cleanly, no self-peer entry is created, and the peer remains responsive. |
|
| S27 | Self-connect rejection | A peer sends `connect` to its own advertised listener address. | The command fails cleanly, no self-peer entry is created, and the peer remains responsive. |
|
||||||
| S28 | Address change without identity change | A known peer is rediscovered with the same peer ID and a different listener address while its library is still known. | The peer record updates in place to the new address, the existing library stays attached to that peer ID, and no duplicate peer entry appears. This is covered with a deterministic unit-level check until the CLI can rebind a live listener without restart. |
|
| S28 | Address change without identity change | A known peer is rediscovered with the same peer ID and a different listener address while its library is still known. | The peer record updates in place to the new address, the existing library stays attached to that peer ID, and no duplicate peer entry appears. This is covered with a deterministic unit-level check until the CLI can rebind a live listener without restart. |
|
||||||
| S29 | Empty-library peer participates | A peer with no games connects into the mesh. | Other peers list it as a peer with zero games; it can receive a download, advertise the new game without restart, and become a source. |
|
| S29 | Empty-library peer participates | A peer with no games connects into the mesh. | Other peers list it as a peer with zero games; it can receive a download, advertise the new game without restart, and become a source. |
|
||||||
| S30 | 5+ peer mesh aggregation | Five peers advertise partially overlapping catalog games with a mix of unique games, shared games, and differing versions; a sixth client connects to all five. | The client shows one row per game ID, correct ready-source `peer_count`, latest `eti_game_version`, no duplicates, and no self entries. |
|
| S30 | 5+ peer mesh aggregation | Five peers advertise partially overlapping catalog games with a mix of unique and shared catalog-version games; a sixth client connects to all five. | The client shows one row per game ID, correct catalog-version ready-source `peer_count`, catalog `eti_game_version`, no duplicates, and no self entries. |
|
||||||
| S31 | Bootstrapped peer becomes source in same session | An empty client downloads a game from a source, the original source shuts down, then a fresh third peer downloads the same game from the bootstrapped client. | The third peer's files match the original source by diff or SHA-256, proving downloaded files become servable without restart. |
|
| S31 | Bootstrapped peer becomes source in same session | An empty client downloads a game from a source, the original source shuts down, then a fresh third peer downloads the same game from the bootstrapped client. | The third peer's files match the original source by diff or SHA-256, proving downloaded files become servable without restart. |
|
||||||
| S32 | Reinstall after uninstall | A downloaded game is installed, uninstalled, then installed again without another download. | `local/` is recreated from preserved root files, no transfer events occur during reinstall, and the game returns to `installed=true`. |
|
| S32 | Reinstall after uninstall | A downloaded game is installed, uninstalled, then installed again without another download. | `local/` is recreated from preserved root files, no transfer events occur during reinstall, and the game returns to `installed=true`. |
|
||||||
| S33 | Install after external root mutation | A downloaded game root is externally mutated before `install` is issued. | The CLI fixture installer installs from the current root bytes. The resulting `local/fixture-payload.txt` must match the mutated archive bytes exactly. |
|
| S33 | Install after external root mutation | A downloaded game root is externally mutated before `install` is issued. | The CLI fixture installer installs from the current root bytes. The resulting `local/fixture-payload.txt` must match the mutated archive bytes exactly. |
|
||||||
| S34 | Many-small-files game without `.eti` | A catalog game root contains `version.ini` plus many small regular files and no archive. | Download with `install=false` transfers every file, chunk events are coherent for small files, and source/receiver manifests match exactly. |
|
| S34 | Many-small-files game without `.eti` | A catalog game root contains `version.ini` plus many small regular files and no archive. | Download with `install=false` transfers every file, chunk events are coherent for small files, and source/receiver manifests match exactly. |
|
||||||
| S35 | Unknown game ID from remote peer | A remote peer advertises a game ID that is not in the receiver's catalog. | The receiver does not list the unknown game as downloadable, download attempts fail deterministically, and no local files are created. |
|
| S35 | Unknown game ID from remote peer | A remote peer advertises a game ID that is not in the receiver's catalog. | The receiver does not list the unknown game as downloadable, download attempts fail deterministically, and no local files are created. |
|
||||||
| S36 | Latest singleton beats stale majority | Five peers advertise one game; one peer has `20260501`, four peers have `20250101`. | `list-games` reports `eti_game_version=20260501`; all descriptors and chunks come from the singleton latest peer; stale peers contribute zero bytes. |
|
| S36 | Catalog singleton beats stale majority | Five peers advertise one game; one peer has the catalog version and four peers have stale versions. | `list-games` reports `peer_count=1` and the catalog `eti_game_version`; all descriptors and chunks come from the singleton catalog-version peer, while stale peers remain hidden and contribute zero bytes. |
|
||||||
| S37 | Single-source download throughput | A source peer advertises a temporary catalog game with one sparse `2 GiB` `.eti`; an empty client downloads it with `install=false`. | The client emits `download-finished` with throughput measurements (`bytes`, `duration_ms`, `mib_per_s`, `mbit_per_s`), and the downloaded archive size matches the source. |
|
| S37 | Single-source download throughput | A source peer advertises a temporary catalog game with one sparse `2 GiB` `.eti`; an empty client downloads it with `install=false`. | The client emits `download-finished` with throughput measurements (`bytes`, `duration_ms`, `mib_per_s`, `mbit_per_s`), and the downloaded archive size matches the source. |
|
||||||
| S38 | First-play launch-setting stamping | `fixture-persona/css` ships a real RAR `.eti` whose tree buries a CRLF `SmartSteamEmu.ini` with a stub `PersonaName` line under `engine/bin/win64/steam_settings/`, plus a stub `account_name.txt` and `language.txt` under `profiles/local/`. A peer installs `css` (with `--unrar`), then sends `play css` with a username and language, then `play css` again. | After install the marker `games/css/launch_settings_applied` is absent and the stub files are intact under `local/`. The first `play` returns `already_applied=false` with `account_name_written`, `language_written`, and `persona_name_written` all true; the deep `SmartSteamEmu.ini` `PersonaName` value becomes the username with its `\r\n` ending and sibling lines preserved, `account_name.txt` becomes the username, `language.txt` becomes the passed language, and the marker now exists. A second `play` returns `already_applied=true`, rewrites nothing, and leaves the files untouched even if their values were reset externally. |
|
| S38 | First-play launch-setting stamping | `fixture-persona/css` ships a real RAR `.eti` whose tree buries a CRLF `SmartSteamEmu.ini` with a stub `PersonaName` line under `engine/bin/win64/steam_settings/`, plus a stub `account_name.txt` and `language.txt` under `profiles/local/`. A peer installs `css` (with `--unrar`), then sends `play css` with a username and language, then `play css` again. | After install the marker `games/css/launch_settings_applied` is absent and the stub files are intact under `local/`. The first `play` returns `already_applied=false` with `account_name_written`, `language_written`, and `persona_name_written` all true; the deep `SmartSteamEmu.ini` `PersonaName` value becomes the username with its `\r\n` ending and sibling lines preserved, `account_name.txt` becomes the username, `language.txt` becomes the passed language, and the marker now exists. A second `play` returns `already_applied=true`, rewrites nothing, and leaves the files untouched even if their values were reset externally. |
|
||||||
| S39 | Streamed install without keeping archive payload | Empty client connects to `fixture-bravo`, then sends `stream-install cnctw`. The source has real RAR `.eti` payload entries under `bin/` and `data/`; the receiver uses the container-bundled `unrar` stream provider. | Client emits `got-game-files`, `download-begin`, streamed `download-chunk-finished`, `download-finished`, `install-begin`, and `install-finished`. Local `cnctw` is `downloaded=false`, `installed=true`, `availability=LocalOnly`; root `version.ini` and `.eti` are absent; `local/bin/cnctw-payload.bin` and `local/data/cnctw-assets.dat` match `unrar p` output by SHA-256. |
|
| S39 | Streamed install without keeping archive payload | Empty client connects to `fixture-bravo`, then sends `stream-install cnctw`. The source has real RAR `.eti` payload entries under `bin/` and `data/`; the receiver uses the container-bundled `unrar` stream provider. | Client emits `got-game-files`, `download-begin`, streamed `download-chunk-finished`, `download-finished`, `install-begin`, and `install-finished`. Local `cnctw` is `downloaded=false`, `installed=true`, `availability=LocalOnly`; root `version.ini` and `.eti` are absent; `local/bin/cnctw-payload.bin` and `local/data/cnctw-assets.dat` match `unrar p` output by SHA-256. |
|
||||||
@@ -58,22 +58,21 @@ for deterministic local runs; mDNS/macvlan remains an environment smoke path.
|
|||||||
|
|
||||||
## Version-Skew Contract
|
## Version-Skew Contract
|
||||||
|
|
||||||
Use S15-S17 to pin down what "newer" means when several peers have the same
|
Use S15-S17 to pin down what happens when several peers have the same game ID
|
||||||
game ID:
|
but only some match the local catalog version:
|
||||||
|
|
||||||
- Version comparison uses the eight-digit `version.ini` string, so use sortable
|
- The receiver's catalog is authoritative. A remote root whose `version.ini`
|
||||||
`YYYYMMDD` values in manual fixtures.
|
does not match the catalog's expected version for that game ID is not
|
||||||
|
downloadable.
|
||||||
- `list-games` aggregates by game ID. The game appears once; `peer_count`
|
- `list-games` aggregates by game ID. The game appears once; `peer_count`
|
||||||
counts all ready peers with that ID, including peers that only have older
|
counts only ready peers with that ID and the catalog version.
|
||||||
versions.
|
- The aggregated `eti_game_version` must be the catalog version.
|
||||||
- The aggregated `eti_game_version` must be the newest ready version.
|
|
||||||
- The descriptor set emitted to the download path, file-size validation, and
|
- The descriptor set emitted to the download path, file-size validation, and
|
||||||
transfer planning are latest-only. Older-version peers may be queried by a
|
transfer planning are catalog-version-only. Stale peers must not supply
|
||||||
generic detail request, but their descriptors must not supply download
|
download descriptors, majority votes, or chunks.
|
||||||
descriptors, majority votes, or chunks once a newer version exists.
|
- If exactly one peer has the catalog version, that peer is the only transfer
|
||||||
- If exactly one peer has the latest version, that peer is the only transfer
|
source. If several peers match the catalog version, validation and chunk
|
||||||
source. If several peers tie on the latest version, validation and chunk
|
fanout happen among that catalog-version set only.
|
||||||
fanout happen among that latest-version set only.
|
|
||||||
- Capture proof with the `list-games` row, `got-game-files` descriptors,
|
- Capture proof with the `list-games` row, `got-game-files` descriptors,
|
||||||
`download-chunk-finished` source addresses, and source/receiver SHA-256
|
`download-chunk-finished` source addresses, and source/receiver SHA-256
|
||||||
manifests.
|
manifests.
|
||||||
@@ -87,7 +86,7 @@ GUI:
|
|||||||
payload files may remain, but they must not be advertised as a ready local
|
payload files may remain, but they must not be advertised as a ready local
|
||||||
game and must not leave an active operation stuck.
|
game and must not leave an active operation stuck.
|
||||||
- Source failure during a redundant download should retry failed chunks against
|
- Source failure during a redundant download should retry failed chunks against
|
||||||
another validated source for the same latest-version file.
|
another validated source for the same catalog-version file.
|
||||||
- Live local library changes are observable by connected peers through library
|
- Live local library changes are observable by connected peers through library
|
||||||
deltas; reconnect is not required for add, remove, or version-bump cases.
|
deltas; reconnect is not required for add, remove, or version-bump cases.
|
||||||
- Same-game operations are single-flight. A duplicate download request while a
|
- Same-game operations are single-flight. A duplicate download request while a
|
||||||
@@ -96,10 +95,10 @@ GUI:
|
|||||||
are not downloadable.
|
are not downloadable.
|
||||||
|
|
||||||
For a manual run, prefer a catalog game ID already served by the fixture lab,
|
For a manual run, prefer a catalog game ID already served by the fixture lab,
|
||||||
such as `cnc4`, then create temporary `just peer-cli-run` game roots with
|
such as `cnc4`, then create temporary `just peer-cli-run` game roots where some
|
||||||
different `version.ini` contents. The existing alpha/bravo/charlie fixtures
|
peers match the catalog version and others deliberately use stale
|
||||||
cover duplicate-source and shared-game cases, but not the three-version skew
|
`version.ini` contents. The existing alpha/bravo/charlie fixtures cover
|
||||||
until a dedicated fixture or temporary games root is prepared.
|
duplicate-source and shared-game cases; S15-S17 add the focused skew cases.
|
||||||
|
|
||||||
## First-Play Launch-Setting Contract
|
## First-Play Launch-Setting Contract
|
||||||
|
|
||||||
@@ -144,6 +143,29 @@ Use S39-S41 to pin down low-disk streamed installs:
|
|||||||
|
|
||||||
## Run Log
|
## Run Log
|
||||||
|
|
||||||
|
### 2026-06-07 - Catalog-Version Matrix Alignment (S1-S47)
|
||||||
|
|
||||||
|
- Code under test aligned checked-in fixture `version.ini` sentinels with the
|
||||||
|
catalog, made `run_extended_scenarios.py` stamp generated fixture games with
|
||||||
|
catalog versions by default, updated S15-S17/S23/S30/S36/S37 to assert
|
||||||
|
catalog-authoritative aggregation, and wired S38 into the executable matrix.
|
||||||
|
- Gates before Docker: `python3 -m py_compile
|
||||||
|
crates/lanspread-peer-cli/scripts/run_extended_scenarios.py` passed.
|
||||||
|
- Targeted rebuilt-image runner:
|
||||||
|
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S3 S8 S14 S15 S16 S17 S21 S22 S23 S24 S29 S30 S31 S34 S36 S37 S39 S40 S41 S42 S43 S44 S45 S46 S47 --build-image`
|
||||||
|
passed.
|
||||||
|
- S38 standalone runner:
|
||||||
|
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py S38`
|
||||||
|
passed, proving the real-RAR `css` fixture installs with the container
|
||||||
|
`/usr/local/bin/unrar` sidecar and stamps launch settings only once.
|
||||||
|
- Full matrix runner:
|
||||||
|
`python3 crates/lanspread-peer-cli/scripts/run_extended_scenarios.py`
|
||||||
|
passed for S1-S47 against the rebuilt `lanspread-peer-cli:dev` image.
|
||||||
|
- The final full-run highlights included S3 aggregation, S15-S17
|
||||||
|
catalog-version skew/fanout/conflict, S23 stale-to-catalog propagation, S30
|
||||||
|
mesh aggregation, S36 catalog singleton over stale majority, S37 throughput,
|
||||||
|
S38 first-play stamping, and S39-S47 streamed-install coverage.
|
||||||
|
|
||||||
### 2026-06-07 - Streamed Install Edge Coverage (S43-S47)
|
### 2026-06-07 - Streamed Install Edge Coverage (S43-S47)
|
||||||
|
|
||||||
- Code under test added `cancel-download` to `lanspread-peer-cli`, added the
|
- Code under test added `cancel-download` to `lanspread-peer-cli`, added the
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
20250101
|
20190317
|
||||||
@@ -1 +1 @@
|
|||||||
20250103
|
20160130
|
||||||
@@ -1 +1 @@
|
|||||||
20250102
|
20200721
|
||||||
@@ -1 +1 @@
|
|||||||
20250201
|
20210416
|
||||||
@@ -1 +1 @@
|
|||||||
20250202
|
20170204
|
||||||
@@ -1 +1 @@
|
|||||||
20250203
|
20160128
|
||||||
@@ -1 +1 @@
|
|||||||
20250102
|
20200721
|
||||||
@@ -1 +1 @@
|
|||||||
20250202
|
20170204
|
||||||
@@ -1 +1 @@
|
|||||||
20250301
|
20160920
|
||||||
@@ -1 +1 @@
|
|||||||
20250302
|
20200315
|
||||||
@@ -1 +1 @@
|
|||||||
20250303
|
20200907
|
||||||
@@ -1 +1 @@
|
|||||||
20250101
|
20240623
|
||||||
@@ -28,7 +28,20 @@ CONTAINER_PREFIX = "lanspread-peer-cli-ext"
|
|||||||
CATALOG_DB = "/app/game.db"
|
CATALOG_DB = "/app/game.db"
|
||||||
FIXTURES = REPO / "crates" / "lanspread-peer-cli" / "fixtures"
|
FIXTURES = REPO / "crates" / "lanspread-peer-cli" / "fixtures"
|
||||||
CHUNK_SIZE = 128 * 1024 * 1024
|
CHUNK_SIZE = 128 * 1024 * 1024
|
||||||
|
CATALOG_VERSIONS = {
|
||||||
|
"alienswarm": "20190317",
|
||||||
|
"bf1942": "20160130",
|
||||||
|
"bfbc2": "20210416",
|
||||||
|
"cnc4": "20170204",
|
||||||
|
"cnctw": "20160128",
|
||||||
|
"cod5": "20160920",
|
||||||
|
"cod6": "20200315",
|
||||||
|
"coh": "20200907",
|
||||||
|
"css": "20240623",
|
||||||
|
"ggoo": "20200721",
|
||||||
|
}
|
||||||
PERF_GAME_ID = "bf1942"
|
PERF_GAME_ID = "bf1942"
|
||||||
|
PERF_GAME_VERSION = CATALOG_VERSIONS[PERF_GAME_ID]
|
||||||
PERF_GAME_SIZE = 2 * 1024 * 1024 * 1024
|
PERF_GAME_SIZE = 2 * 1024 * 1024 * 1024
|
||||||
IGNORED_DIFF_NAMES = {".lanspread", ".lanspread.json", "local"}
|
IGNORED_DIFF_NAMES = {".lanspread", ".lanspread.json", "local"}
|
||||||
|
|
||||||
@@ -305,8 +318,8 @@ class Runner:
|
|||||||
("S13", self.s13_exact_transfer_equality),
|
("S13", self.s13_exact_transfer_equality),
|
||||||
("S14", self.s14_large_multi_peer_chunking),
|
("S14", self.s14_large_multi_peer_chunking),
|
||||||
("S15", self.s15_three_way_version_skew),
|
("S15", self.s15_three_way_version_skew),
|
||||||
("S16", self.s16_latest_fanout_with_stale),
|
("S16", self.s16_catalog_fanout_with_stale),
|
||||||
("S17", self.s17_latest_conflict_rejection),
|
("S17", self.s17_catalog_conflict_rejection),
|
||||||
("S18", self.s18_redundant_source_drop),
|
("S18", self.s18_redundant_source_drop),
|
||||||
("S19", self.s19_sole_source_drop),
|
("S19", self.s19_sole_source_drop),
|
||||||
("S20", self.s20_receiver_write_failure),
|
("S20", self.s20_receiver_write_failure),
|
||||||
@@ -325,8 +338,9 @@ class Runner:
|
|||||||
("S33", self.s33_install_after_mutation),
|
("S33", self.s33_install_after_mutation),
|
||||||
("S34", self.s34_many_small_files),
|
("S34", self.s34_many_small_files),
|
||||||
("S35", self.s35_unknown_game_filtered),
|
("S35", self.s35_unknown_game_filtered),
|
||||||
("S36", self.s36_latest_singleton),
|
("S36", self.s36_catalog_singleton),
|
||||||
("S37", self.s37_single_source_download_throughput),
|
("S37", self.s37_single_source_download_throughput),
|
||||||
|
("S38", self.s38_first_play_launch_settings),
|
||||||
("S39", self.s39_streamed_install_local_only),
|
("S39", self.s39_streamed_install_local_only),
|
||||||
("S40", self.s40_streamed_receiver_not_source),
|
("S40", self.s40_streamed_receiver_not_source),
|
||||||
("S41", self.s41_solid_archive_streamed_install),
|
("S41", self.s41_solid_archive_streamed_install),
|
||||||
@@ -520,20 +534,20 @@ class Runner:
|
|||||||
def s8_ambiguous_metadata_rejection(self) -> str:
|
def s8_ambiguous_metadata_rejection(self) -> str:
|
||||||
dir_a = self.fixture_root / "s8-a"
|
dir_a = self.fixture_root / "s8-a"
|
||||||
dir_b = self.fixture_root / "s8-b"
|
dir_b = self.fixture_root / "s8-b"
|
||||||
copy_game("ggoo", dir_a, version="20260101")
|
copy_game("ggoo", dir_a)
|
||||||
copy_game("ggoo", dir_b, version="20260101")
|
copy_game("ggoo", dir_b)
|
||||||
with (dir_b / "ggoo" / "ggoo.eti").open("ab") as handle:
|
with (dir_b / "ggoo" / "ggoo.eti").open("ab") as handle:
|
||||||
handle.write(b"conflict")
|
handle.write(b"conflict")
|
||||||
peer_a = self.peer("s8-a", games_dir=dir_a)
|
peer_a = self.peer("s8-a", games_dir=dir_a)
|
||||||
peer_b = self.peer("s8-b", games_dir=dir_b)
|
peer_b = self.peer("s8-b", games_dir=dir_b)
|
||||||
client = self.peer("s8-client")
|
client = self.peer("s8-client")
|
||||||
connect_many(client, [peer_a, peer_b])
|
connect_many(client, [peer_a, peer_b])
|
||||||
wait_remote_game(client, "ggoo", peer_count=2, version="20260101")
|
wait_remote_game(client, "ggoo", peer_count=2, version=CATALOG_VERSIONS["ggoo"])
|
||||||
waiter = LineWaiter(len(client.output))
|
waiter = LineWaiter(len(client.output))
|
||||||
client.send({"cmd": "download", "game_id": "ggoo", "install": False})
|
client.send({"cmd": "download", "game_id": "ggoo", "install": False})
|
||||||
client.wait_for(event_is("download-failed", "ggoo"), timeout=30, description="ggoo failed", waiter=waiter)
|
client.wait_for(event_is("download-failed", "ggoo"), timeout=30, description="ggoo failed", waiter=waiter)
|
||||||
assert_not_exists(client.host_games_dir / "ggoo" / "version.ini")
|
assert_not_exists(client.host_games_dir / "ggoo" / "version.ini")
|
||||||
return "conflicting latest ggoo file sizes emitted download-failed and left no version.ini"
|
return "conflicting catalog-version ggoo file sizes emitted download-failed and left no version.ini"
|
||||||
|
|
||||||
def s9_missing_game(self) -> str:
|
def s9_missing_game(self) -> str:
|
||||||
client = self.peer("s9-client")
|
client = self.peer("s9-client")
|
||||||
@@ -615,7 +629,7 @@ class Runner:
|
|||||||
diff_game_dirs(source_dir / game_id, stage.host_games_dir / game_id)
|
diff_game_dirs(source_dir / game_id, stage.host_games_dir / game_id)
|
||||||
client = self.peer("s14-client")
|
client = self.peer("s14-client")
|
||||||
connect_many(client, [alpha, stage])
|
connect_many(client, [alpha, stage])
|
||||||
wait_remote_game(client, game_id, peer_count=2, version="20260520")
|
wait_remote_game(client, game_id, peer_count=2, version=PERF_GAME_VERSION)
|
||||||
waiter = LineWaiter(len(client.output))
|
waiter = LineWaiter(len(client.output))
|
||||||
client.send({"cmd": "download", "game_id": game_id, "install": False})
|
client.send({"cmd": "download", "game_id": game_id, "install": False})
|
||||||
client.wait_for(event_is("download-finished", game_id), timeout=90, description="client finish", waiter=waiter)
|
client.wait_for(event_is("download-finished", game_id), timeout=90, description="client finish", waiter=waiter)
|
||||||
@@ -629,7 +643,11 @@ class Runner:
|
|||||||
return f"{game_id} downloaded from two sources, diff matched, chunk totals={totals}"
|
return f"{game_id} downloaded from two sources, diff matched, chunk totals={totals}"
|
||||||
|
|
||||||
def s15_three_way_version_skew(self) -> str:
|
def s15_three_way_version_skew(self) -> str:
|
||||||
specs = [("s15-a", "20250101"), ("s15-b", "20250201"), ("s15-c", "20250301")]
|
specs = [
|
||||||
|
("s15-a", "20150101"),
|
||||||
|
("s15-b", "20160101"),
|
||||||
|
("s15-c", CATALOG_VERSIONS["cnc4"]),
|
||||||
|
]
|
||||||
peers = []
|
peers = []
|
||||||
for name, version in specs:
|
for name, version in specs:
|
||||||
game_dir = self.fixture_root / name
|
game_dir = self.fixture_root / name
|
||||||
@@ -637,19 +655,19 @@ class Runner:
|
|||||||
peers.append(self.peer(name, games_dir=game_dir))
|
peers.append(self.peer(name, games_dir=game_dir))
|
||||||
client = self.peer("s15-client")
|
client = self.peer("s15-client")
|
||||||
connect_many(client, peers)
|
connect_many(client, peers)
|
||||||
wait_remote_game(client, "cnc4", peer_count=3, version="20250301")
|
wait_remote_game(client, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
|
||||||
waiter = LineWaiter(len(client.output))
|
waiter = LineWaiter(len(client.output))
|
||||||
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
|
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
|
||||||
client.wait_for(event_is("download-finished", "cnc4"), timeout=60, description="cnc4 finish", waiter=waiter)
|
client.wait_for(event_is("download-finished", "cnc4"), timeout=60, description="cnc4 finish", waiter=waiter)
|
||||||
assert_only_chunk_sources(client, "cnc4", {peers[2].ready_addr})
|
assert_only_chunk_sources(client, "cnc4", {peers[2].ready_addr})
|
||||||
diff_game_dirs(peers[2].host_games_dir / "cnc4", client.host_games_dir / "cnc4")
|
diff_game_dirs(peers[2].host_games_dir / "cnc4", client.host_games_dir / "cnc4")
|
||||||
return "three-way skew selected only 20250301 peer and receiver diffed cleanly"
|
return "three-way skew exposed only the catalog-version peer and receiver diffed cleanly"
|
||||||
|
|
||||||
def s16_latest_fanout_with_stale(self) -> str:
|
def s16_catalog_fanout_with_stale(self) -> str:
|
||||||
specs = [
|
specs = [
|
||||||
("s16-a", "20250101"),
|
("s16-a", "20180101"),
|
||||||
("s16-b", "20250301"),
|
("s16-b", CATALOG_VERSIONS["alienswarm"]),
|
||||||
("s16-c", "20250301"),
|
("s16-c", CATALOG_VERSIONS["alienswarm"]),
|
||||||
]
|
]
|
||||||
peers = []
|
peers = []
|
||||||
for name, version in specs:
|
for name, version in specs:
|
||||||
@@ -658,7 +676,7 @@ class Runner:
|
|||||||
peers.append(self.peer(name, games_dir=game_dir))
|
peers.append(self.peer(name, games_dir=game_dir))
|
||||||
client = self.peer("s16-client")
|
client = self.peer("s16-client")
|
||||||
connect_many(client, peers)
|
connect_many(client, peers)
|
||||||
wait_remote_game(client, "alienswarm", peer_count=3, version="20250301")
|
wait_remote_game(client, "alienswarm", peer_count=2, version=CATALOG_VERSIONS["alienswarm"])
|
||||||
waiter = LineWaiter(len(client.output))
|
waiter = LineWaiter(len(client.output))
|
||||||
client.send({"cmd": "download", "game_id": "alienswarm", "install": False})
|
client.send({"cmd": "download", "game_id": "alienswarm", "install": False})
|
||||||
client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="alienswarm finish", waiter=waiter)
|
client.wait_for(event_is("download-finished", "alienswarm"), timeout=90, description="alienswarm finish", waiter=waiter)
|
||||||
@@ -667,13 +685,13 @@ class Runner:
|
|||||||
if peers[0].ready_addr in totals:
|
if peers[0].ready_addr in totals:
|
||||||
raise ScenarioError(f"stale peer contributed chunks: {totals}")
|
raise ScenarioError(f"stale peer contributed chunks: {totals}")
|
||||||
diff_game_dirs(peers[1].host_games_dir / "alienswarm", client.host_games_dir / "alienswarm")
|
diff_game_dirs(peers[1].host_games_dir / "alienswarm", client.host_games_dir / "alienswarm")
|
||||||
return f"latest B/C peers served alienswarm while stale A contributed zero; totals={totals}"
|
return f"catalog-version B/C peers served alienswarm while stale A contributed zero; totals={totals}"
|
||||||
|
|
||||||
def s17_latest_conflict_rejection(self) -> str:
|
def s17_catalog_conflict_rejection(self) -> str:
|
||||||
specs = [
|
specs = [
|
||||||
("s17-a", "20250101", False),
|
("s17-a", "20150101", False),
|
||||||
("s17-b", "20250301", False),
|
("s17-b", CATALOG_VERSIONS["cnc4"], False),
|
||||||
("s17-c", "20250301", True),
|
("s17-c", CATALOG_VERSIONS["cnc4"], True),
|
||||||
]
|
]
|
||||||
peers = []
|
peers = []
|
||||||
for name, version, conflict in specs:
|
for name, version, conflict in specs:
|
||||||
@@ -685,12 +703,12 @@ class Runner:
|
|||||||
peers.append(self.peer(name, games_dir=game_dir))
|
peers.append(self.peer(name, games_dir=game_dir))
|
||||||
client = self.peer("s17-client")
|
client = self.peer("s17-client")
|
||||||
connect_many(client, peers)
|
connect_many(client, peers)
|
||||||
wait_remote_game(client, "cnc4", peer_count=3, version="20250301")
|
wait_remote_game(client, "cnc4", peer_count=2, version=CATALOG_VERSIONS["cnc4"])
|
||||||
waiter = LineWaiter(len(client.output))
|
waiter = LineWaiter(len(client.output))
|
||||||
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
|
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
|
||||||
client.wait_for(event_is("download-failed", "cnc4"), timeout=30, description="cnc4 failed", waiter=waiter)
|
client.wait_for(event_is("download-failed", "cnc4"), timeout=30, description="cnc4 failed", waiter=waiter)
|
||||||
assert_not_exists(client.host_games_dir / "cnc4" / "version.ini")
|
assert_not_exists(client.host_games_dir / "cnc4" / "version.ini")
|
||||||
return "latest-version file conflict failed download and left no committed version.ini"
|
return "catalog-version file conflict failed download and left no committed version.ini"
|
||||||
|
|
||||||
def s18_redundant_source_drop(self) -> str:
|
def s18_redundant_source_drop(self) -> str:
|
||||||
source_a_dir = self.fixture_root / "s18-a"
|
source_a_dir = self.fixture_root / "s18-a"
|
||||||
@@ -773,13 +791,13 @@ class Runner:
|
|||||||
def s23_version_bump_propagation(self) -> str:
|
def s23_version_bump_propagation(self) -> str:
|
||||||
alpha = self.peer("s23-alpha")
|
alpha = self.peer("s23-alpha")
|
||||||
bravo_dir = self.fixture_root / "s23-bravo"
|
bravo_dir = self.fixture_root / "s23-bravo"
|
||||||
copy_game("cnc4", bravo_dir, version="20250101")
|
copy_game("cnc4", bravo_dir, version="20160101")
|
||||||
bravo = self.peer("s23-bravo", games_dir=bravo_dir)
|
bravo = self.peer("s23-bravo", games_dir=bravo_dir)
|
||||||
connect_many(alpha, [bravo])
|
connect_many(alpha, [bravo])
|
||||||
wait_remote_game(alpha, "cnc4", peer_count=1, version="20250101")
|
wait_remote_absent(alpha, "cnc4", timeout=5)
|
||||||
(bravo_dir / "cnc4" / "version.ini").write_text("20260501", encoding="utf-8")
|
(bravo_dir / "cnc4" / "version.ini").write_text(CATALOG_VERSIONS["cnc4"], encoding="utf-8")
|
||||||
wait_remote_game(alpha, "cnc4", peer_count=1, version="20260501")
|
wait_remote_game(alpha, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
|
||||||
return "alpha observed cnc4 eti_game_version change 20250101 -> 20260501 without reconnect"
|
return "alpha observed stale cnc4 become catalog-version downloadable without reconnect"
|
||||||
|
|
||||||
def s24_two_clients_one_source(self) -> str:
|
def s24_two_clients_one_source(self) -> str:
|
||||||
source = self.peer("s24-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
|
source = self.peer("s24-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
|
||||||
@@ -876,11 +894,11 @@ class Runner:
|
|||||||
def s30_mesh_aggregation(self) -> str:
|
def s30_mesh_aggregation(self) -> str:
|
||||||
dirs = []
|
dirs = []
|
||||||
specs = [
|
specs = [
|
||||||
("s30-a", [("ggoo", "20250101"), ("bf1942", "20250101")]),
|
("s30-a", [("ggoo", CATALOG_VERSIONS["ggoo"]), ("bf1942", CATALOG_VERSIONS["bf1942"])]),
|
||||||
("s30-b", [("ggoo", "20250101"), ("cnc4", "20250101")]),
|
("s30-b", [("ggoo", CATALOG_VERSIONS["ggoo"]), ("cnc4", CATALOG_VERSIONS["cnc4"])]),
|
||||||
("s30-c", [("cnc4", "20250301"), ("cod5", "20250101")]),
|
("s30-c", [("cnc4", CATALOG_VERSIONS["cnc4"]), ("cod5", CATALOG_VERSIONS["cod5"])]),
|
||||||
("s30-d", [("cnctw", "20250101"), ("coh", "20250101")]),
|
("s30-d", [("cnctw", CATALOG_VERSIONS["cnctw"]), ("coh", CATALOG_VERSIONS["coh"])]),
|
||||||
("s30-e", [("cnctw", "20250201"), ("bf1942", "20250201")]),
|
("s30-e", [("cnctw", CATALOG_VERSIONS["cnctw"]), ("bf1942", CATALOG_VERSIONS["bf1942"])]),
|
||||||
]
|
]
|
||||||
peers = []
|
peers = []
|
||||||
for name, games in specs:
|
for name, games in specs:
|
||||||
@@ -892,12 +910,12 @@ class Runner:
|
|||||||
client = self.peer("s30-client")
|
client = self.peer("s30-client")
|
||||||
connect_many(client, peers)
|
connect_many(client, peers)
|
||||||
expected = {
|
expected = {
|
||||||
"ggoo": (2, "20250101"),
|
"ggoo": (2, CATALOG_VERSIONS["ggoo"]),
|
||||||
"bf1942": (2, "20250201"),
|
"bf1942": (2, CATALOG_VERSIONS["bf1942"]),
|
||||||
"cnc4": (2, "20250301"),
|
"cnc4": (2, CATALOG_VERSIONS["cnc4"]),
|
||||||
"cod5": (1, "20250101"),
|
"cod5": (1, CATALOG_VERSIONS["cod5"]),
|
||||||
"cnctw": (2, "20250201"),
|
"cnctw": (2, CATALOG_VERSIONS["cnctw"]),
|
||||||
"coh": (1, "20250101"),
|
"coh": (1, CATALOG_VERSIONS["coh"]),
|
||||||
}
|
}
|
||||||
for game_id, (peer_count, version) in expected.items():
|
for game_id, (peer_count, version) in expected.items():
|
||||||
wait_remote_game(client, game_id, peer_count=peer_count, version=version)
|
wait_remote_game(client, game_id, peer_count=peer_count, version=version)
|
||||||
@@ -907,7 +925,7 @@ class Runner:
|
|||||||
raise ScenarioError(f"duplicate game rows: {ids}")
|
raise ScenarioError(f"duplicate game rows: {ids}")
|
||||||
if any(peer["peer_id"] == client.peer_id for peer in client.list_peers()):
|
if any(peer["peer_id"] == client.peer_id for peer in client.list_peers()):
|
||||||
raise ScenarioError("client listed itself as a peer")
|
raise ScenarioError("client listed itself as a peer")
|
||||||
return f"client aggregated {len(expected)} IDs from 5 peers with expected peer_count/latest versions"
|
return f"client aggregated {len(expected)} IDs from 5 peers with expected peer_count/catalog versions"
|
||||||
|
|
||||||
def s31_bootstrapped_peer_source(self) -> str:
|
def s31_bootstrapped_peer_source(self) -> str:
|
||||||
source = self.peer("s31-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
|
source = self.peer("s31-alpha", games_dir=FIXTURES / "fixture-alpha", readonly_games=True)
|
||||||
@@ -1003,34 +1021,34 @@ class Runner:
|
|||||||
assert_not_exists(client.host_games_dir / "mystery-game")
|
assert_not_exists(client.host_games_dir / "mystery-game")
|
||||||
return f"unknown game absent from list-games; download errored '{err['error']}'; no local files"
|
return f"unknown game absent from list-games; download errored '{err['error']}'; no local files"
|
||||||
|
|
||||||
def s36_latest_singleton(self) -> str:
|
def s36_catalog_singleton(self) -> str:
|
||||||
peers = []
|
peers = []
|
||||||
for index in range(5):
|
for index in range(5):
|
||||||
game_dir = self.fixture_root / f"s36-{index}"
|
game_dir = self.fixture_root / f"s36-{index}"
|
||||||
version = "20260501" if index == 0 else "20250101"
|
version = CATALOG_VERSIONS["cnc4"] if index == 0 else "20160101"
|
||||||
copy_game("cnc4", game_dir, version=version)
|
copy_game("cnc4", game_dir, version=version)
|
||||||
peers.append(self.peer(f"s36-{index}", games_dir=game_dir))
|
peers.append(self.peer(f"s36-{index}", games_dir=game_dir))
|
||||||
client = self.peer("s36-client")
|
client = self.peer("s36-client")
|
||||||
connect_many(client, peers)
|
connect_many(client, peers)
|
||||||
wait_remote_game(client, "cnc4", peer_count=5, version="20260501")
|
wait_remote_game(client, "cnc4", peer_count=1, version=CATALOG_VERSIONS["cnc4"])
|
||||||
waiter = LineWaiter(len(client.output))
|
waiter = LineWaiter(len(client.output))
|
||||||
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
|
client.send({"cmd": "download", "game_id": "cnc4", "install": False})
|
||||||
got = client.wait_for(event_is("got-game-files", "cnc4"), timeout=20, description="got game files", waiter=waiter)
|
got = client.wait_for(event_is("got-game-files", "cnc4"), timeout=20, description="got game files", waiter=waiter)
|
||||||
client.wait_for(event_is("download-finished", "cnc4"), timeout=60, description="download finish", waiter=waiter)
|
client.wait_for(event_is("download-finished", "cnc4"), timeout=60, description="download finish", waiter=waiter)
|
||||||
latest_addr = peers[0].ready_addr
|
catalog_addr = peers[0].ready_addr
|
||||||
if latest_addr is None:
|
if catalog_addr is None:
|
||||||
raise ScenarioError("latest peer had no ready addr")
|
raise ScenarioError("catalog-version peer had no ready addr")
|
||||||
for item in client.output:
|
for item in client.output:
|
||||||
if item.get("type") != "event" or item.get("event") != "download-chunk-finished":
|
if item.get("type") != "event" or item.get("event") != "download-chunk-finished":
|
||||||
continue
|
continue
|
||||||
data = item["data"]
|
data = item["data"]
|
||||||
if data.get("game_id") == "cnc4" and data.get("peer_addr") != latest_addr:
|
if data.get("game_id") == "cnc4" and data.get("peer_addr") != catalog_addr:
|
||||||
raise ScenarioError(f"stale peer contributed chunk: {data}")
|
raise ScenarioError(f"stale peer contributed chunk: {data}")
|
||||||
diff_game_dirs(peers[0].host_games_dir / "cnc4", client.host_games_dir / "cnc4")
|
diff_game_dirs(peers[0].host_games_dir / "cnc4", client.host_games_dir / "cnc4")
|
||||||
descs = got["data"]["file_descriptions"]
|
descs = got["data"]["file_descriptions"]
|
||||||
if not descs:
|
if not descs:
|
||||||
raise ScenarioError("got-game-files had no descriptors")
|
raise ScenarioError("got-game-files had no descriptors")
|
||||||
return "client reported latest 20260501 with peer_count=5; only singleton latest peer sent chunks; diff matched"
|
return "client reported singleton catalog-version peer; stale peers stayed hidden and sent no chunks; diff matched"
|
||||||
|
|
||||||
def s37_single_source_download_throughput(self) -> str:
|
def s37_single_source_download_throughput(self) -> str:
|
||||||
source_dir = self.fixture_root / "s37-source"
|
source_dir = self.fixture_root / "s37-source"
|
||||||
@@ -1038,7 +1056,7 @@ class Runner:
|
|||||||
source = self.peer("s37-source", games_dir=source_dir)
|
source = self.peer("s37-source", games_dir=source_dir)
|
||||||
client = self.peer("s37-client")
|
client = self.peer("s37-client")
|
||||||
connect_many(client, [source])
|
connect_many(client, [source])
|
||||||
wait_remote_game(client, PERF_GAME_ID, peer_count=1, version="20260520")
|
wait_remote_game(client, PERF_GAME_ID, peer_count=1, version=PERF_GAME_VERSION)
|
||||||
|
|
||||||
waiter = LineWaiter(len(client.output))
|
waiter = LineWaiter(len(client.output))
|
||||||
client.send({"cmd": "download", "game_id": PERF_GAME_ID, "install": False})
|
client.send({"cmd": "download", "game_id": PERF_GAME_ID, "install": False})
|
||||||
@@ -1057,7 +1075,7 @@ class Runner:
|
|||||||
throughput = finished.get("data", {}).get("throughput")
|
throughput = finished.get("data", {}).get("throughput")
|
||||||
if not throughput:
|
if not throughput:
|
||||||
raise ScenarioError(f"download-finished did not include throughput: {finished}")
|
raise ScenarioError(f"download-finished did not include throughput: {finished}")
|
||||||
expected_bytes = PERF_GAME_SIZE + len("20260520")
|
expected_bytes = PERF_GAME_SIZE + len(PERF_GAME_VERSION)
|
||||||
if int(throughput["bytes"]) != expected_bytes:
|
if int(throughput["bytes"]) != expected_bytes:
|
||||||
raise ScenarioError(
|
raise ScenarioError(
|
||||||
f"throughput byte count mismatch: {throughput['bytes']} != {expected_bytes}"
|
f"throughput byte count mismatch: {throughput['bytes']} != {expected_bytes}"
|
||||||
@@ -1071,6 +1089,116 @@ class Runner:
|
|||||||
f"{throughput['chunks']} chunks"
|
f"{throughput['chunks']} chunks"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def s38_first_play_launch_settings(self) -> str:
|
||||||
|
client_dir = self.fixture_root / "s38-client"
|
||||||
|
copy_game("css", client_dir)
|
||||||
|
client = self.peer(
|
||||||
|
"s38-client",
|
||||||
|
games_dir=client_dir,
|
||||||
|
extra_args=["--unrar", "/usr/local/bin/unrar"],
|
||||||
|
)
|
||||||
|
waiter = LineWaiter(len(client.output))
|
||||||
|
client.send({"cmd": "install", "game_id": "css"})
|
||||||
|
client.wait_for(
|
||||||
|
event_is("install-finished", "css"),
|
||||||
|
timeout=30,
|
||||||
|
description="css install",
|
||||||
|
waiter=waiter,
|
||||||
|
)
|
||||||
|
wait_local_game(client, "css", downloaded=True, installed=True)
|
||||||
|
|
||||||
|
marker = client.host_state_dir / "games" / "css" / "launch_settings_applied"
|
||||||
|
if marker.exists():
|
||||||
|
raise ScenarioError("launch settings marker existed before first play")
|
||||||
|
|
||||||
|
local_root = client.host_games_dir / "css" / "local"
|
||||||
|
account_file = local_root / "profiles" / "local" / "account_name.txt"
|
||||||
|
language_file = local_root / "profiles" / "local" / "language.txt"
|
||||||
|
ini_file = (
|
||||||
|
local_root
|
||||||
|
/ "engine"
|
||||||
|
/ "bin"
|
||||||
|
/ "win64"
|
||||||
|
/ "steam_settings"
|
||||||
|
/ "SmartSteamEmu.ini"
|
||||||
|
)
|
||||||
|
for path in [account_file, language_file, ini_file]:
|
||||||
|
if not path.is_file():
|
||||||
|
raise ScenarioError(f"expected installed launch settings file: {path}")
|
||||||
|
if b"PersonaName = stubplayer\r\n" not in ini_file.read_bytes():
|
||||||
|
raise ScenarioError("installed SmartSteamEmu.ini did not preserve CRLF stub PersonaName")
|
||||||
|
|
||||||
|
first = client.send(
|
||||||
|
{
|
||||||
|
"cmd": "play",
|
||||||
|
"game_id": "css",
|
||||||
|
"username": "Lan Hero",
|
||||||
|
"language": "german",
|
||||||
|
}
|
||||||
|
)["data"]["outcome"]
|
||||||
|
expected_first = {
|
||||||
|
"already_applied": False,
|
||||||
|
"account_name_written": True,
|
||||||
|
"language_written": True,
|
||||||
|
"persona_name_written": True,
|
||||||
|
}
|
||||||
|
if first != expected_first:
|
||||||
|
raise ScenarioError(f"unexpected first play outcome: {first}")
|
||||||
|
if not marker.is_file():
|
||||||
|
raise ScenarioError("launch settings marker was not written after first play")
|
||||||
|
if account_file.read_text(encoding="utf-8") != "Lan Hero":
|
||||||
|
raise ScenarioError("account_name.txt was not stamped with username")
|
||||||
|
if language_file.read_text(encoding="utf-8") != "german":
|
||||||
|
raise ScenarioError("language.txt was not stamped with language")
|
||||||
|
stamped_ini = ini_file.read_bytes()
|
||||||
|
if b"PersonaName = Lan Hero\r\n" not in stamped_ini:
|
||||||
|
raise ScenarioError("PersonaName was not stamped with CRLF preserved")
|
||||||
|
if b"AppId = 240\r\n" not in stamped_ini or b"Language = english\r\n" not in stamped_ini:
|
||||||
|
raise ScenarioError("SmartSteamEmu.ini sibling lines were not preserved")
|
||||||
|
|
||||||
|
client.docker_exec(
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
"printf resetaccount > /games/css/local/profiles/local/account_name.txt",
|
||||||
|
)
|
||||||
|
client.docker_exec(
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
"printf resetlang > /games/css/local/profiles/local/language.txt",
|
||||||
|
)
|
||||||
|
client.docker_exec(
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
"printf '[Settings]\\r\\nAppId = 240\\r\\n"
|
||||||
|
"PersonaName = resetplayer\\r\\nLanguage = english\\r\\n' > "
|
||||||
|
"/games/css/local/engine/bin/win64/steam_settings/SmartSteamEmu.ini",
|
||||||
|
)
|
||||||
|
|
||||||
|
second = client.send(
|
||||||
|
{
|
||||||
|
"cmd": "play",
|
||||||
|
"game_id": "css",
|
||||||
|
"username": "Second User",
|
||||||
|
"language": "french",
|
||||||
|
}
|
||||||
|
)["data"]["outcome"]
|
||||||
|
expected_second = {
|
||||||
|
"already_applied": True,
|
||||||
|
"account_name_written": False,
|
||||||
|
"language_written": False,
|
||||||
|
"persona_name_written": False,
|
||||||
|
}
|
||||||
|
if second != expected_second:
|
||||||
|
raise ScenarioError(f"unexpected second play outcome: {second}")
|
||||||
|
if account_file.read_text(encoding="utf-8") != "resetaccount":
|
||||||
|
raise ScenarioError("second play rewrote account_name.txt despite marker")
|
||||||
|
if language_file.read_text(encoding="utf-8") != "resetlang":
|
||||||
|
raise ScenarioError("second play rewrote language.txt despite marker")
|
||||||
|
if b"PersonaName = resetplayer\r\n" not in ini_file.read_bytes():
|
||||||
|
raise ScenarioError("second play rewrote PersonaName despite marker")
|
||||||
|
|
||||||
|
return "css first play stamped launch settings once; second play respected the marker"
|
||||||
|
|
||||||
def stream_install_cnctw(self, prefix: str) -> tuple[Peer, Peer]:
|
def stream_install_cnctw(self, prefix: str) -> tuple[Peer, Peer]:
|
||||||
source_dir = self.fixture_root / f"{prefix}-bravo"
|
source_dir = self.fixture_root / f"{prefix}-bravo"
|
||||||
copy_game("cnctw", source_dir, version="20160128")
|
copy_game("cnctw", source_dir, version="20160128")
|
||||||
@@ -1574,6 +1702,7 @@ def copy_game(game_id: str, destination_games_dir: Path, *, version: str | None
|
|||||||
shutil.rmtree(destination)
|
shutil.rmtree(destination)
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.copytree(source, destination)
|
shutil.copytree(source, destination)
|
||||||
|
version = version if version is not None else CATALOG_VERSIONS.get(game_id)
|
||||||
if version is not None:
|
if version is not None:
|
||||||
(destination / "version.ini").write_text(version, encoding="utf-8")
|
(destination / "version.ini").write_text(version, encoding="utf-8")
|
||||||
|
|
||||||
@@ -1610,14 +1739,14 @@ def create_many_small_game(root: Path) -> None:
|
|||||||
for index in range(20):
|
for index in range(20):
|
||||||
child = root / f"file-{index:02}.bin"
|
child = root / f"file-{index:02}.bin"
|
||||||
child.write_bytes(hashlib.sha256(f"small-{index}".encode()).digest() * 8)
|
child.write_bytes(hashlib.sha256(f"small-{index}".encode()).digest() * 8)
|
||||||
(root / "version.ini").write_text("20250101", encoding="utf-8")
|
(root / "version.ini").write_text(CATALOG_VERSIONS.get(root.name, "20250101"), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def create_large_sparse_game(root: Path, *, size: int) -> None:
|
def create_large_sparse_game(root: Path, *, size: int) -> None:
|
||||||
if root.exists():
|
if root.exists():
|
||||||
shutil.rmtree(root)
|
shutil.rmtree(root)
|
||||||
root.mkdir(parents=True)
|
root.mkdir(parents=True)
|
||||||
(root / "version.ini").write_text("20260520", encoding="utf-8")
|
(root / "version.ini").write_text(PERF_GAME_VERSION, encoding="utf-8")
|
||||||
archive = root / f"{root.name}.eti"
|
archive = root / f"{root.name}.eti"
|
||||||
with archive.open("wb") as handle:
|
with archive.open("wb") as handle:
|
||||||
handle.truncate(size)
|
handle.truncate(size)
|
||||||
|
|||||||
Reference in New Issue
Block a user