diff --git a/.github/workflows/update-mappings.yml b/.github/workflows/update-mappings.yml index 8760bb3..d24223a 100644 --- a/.github/workflows/update-mappings.yml +++ b/.github/workflows/update-mappings.yml @@ -1,4 +1,4 @@ -name: Update reward mappings +name: Update mappings on: workflow_dispatch: @@ -14,7 +14,7 @@ permissions: pull-requests: write concurrency: - group: update-reward-mappings + group: update-mappings cancel-in-progress: false jobs: @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest env: - AUTOMATION_BRANCH: dev/automated-mapping-update + AUTOMATION_BRANCH: dev/automated-mapping-update-${{ github.ref_name }} NTE_ASSETS_REF: ${{ inputs.source_ref }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -32,7 +32,7 @@ jobs: uses: actions/checkout@v5 with: fetch-depth: 0 - ref: ${{ github.event.repository.default_branch }} + ref: ${{ github.ref_name }} - name: Get Python uses: actions/setup-python@v6 @@ -42,7 +42,7 @@ jobs: - name: Install project run: python -m pip install -e . - - name: Rebuild reward mappings + - name: Rebuild mappings run: python tools/update_mappings.py --source-ref "$NTE_ASSETS_REF" --apply - name: Run tests @@ -83,15 +83,15 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout -B "$AUTOMATION_BRANCH" - git add mappings/arcs.json mappings/characters.json mappings/items.json - git commit -m "chore: update reward mappings from NTE_Assets" + git add mappings/arcs.json mappings/characters.json mappings/items.json mappings/achievements.json + git commit -m "chore: update mappings from NTE_Assets" git push --force origin "HEAD:refs/heads/$AUTOMATION_BRANCH" - name: Open or update pull request if: steps.changes.outputs.changed == 'true' shell: bash env: - BASE_BRANCH: ${{ github.event.repository.default_branch }} + BASE_BRANCH: ${{ github.ref_name }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | python - <<'PY' @@ -108,7 +108,7 @@ jobs: f"- {changes['additions']} additions\n" f"- {changes['updates']} updates\n" f"- {changes['deletions']} deletions\n\n" - "The workflow rebuilt all reward mappings, resolved English names through " + "The workflow rebuilt all reward and achievement mappings, resolved English text through " "`Localization/en/game.json`, and passed the complete test suite. Pool mapping " "files and UID inputs were not changed.\n\n" f"[Workflow run]({os.environ['RUN_URL']})\n" @@ -125,12 +125,12 @@ jobs: if [ -n "$pr_number" ]; then gh pr edit "$pr_number" \ - --title "chore: update reward mappings from NTE_Assets" \ + --title "chore: update mappings from NTE_Assets" \ --body-file build/mapping-update/pull-request-body.md else gh pr create \ --head "$AUTOMATION_BRANCH" \ --base "$BASE_BRANCH" \ - --title "chore: update reward mappings from NTE_Assets" \ + --title "chore: update mappings from NTE_Assets" \ --body-file build/mapping-update/pull-request-body.md fi diff --git a/README.md b/README.md index 48d9e6b..b15f290 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # NTE History Exporter -Prototype CLI exporter for **Neverness to Everness** pull history — decodes your own game traffic into sanitized JSON ready for tracker import. +Prototype CLI exporter for **Neverness to Everness** pull history and achievements — decodes your own game traffic into sanitized JSON. ![Python](https://img.shields.io/badge/python-3.10+-blue) ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-0078D6) @@ -23,10 +23,17 @@ Prototype CLI exporter for **Neverness to Everness** pull history — decodes yo ## What It Does -The exporter decodes Permanent Board, Limited Character Board, Arc Miracle Box, and Mystery Box history pages from captured UDP data, applies conservative timestamp-boundary handling, and writes sanitized JSON suitable for tracker import. +The exporter decodes Permanent Board, Limited Character Board, Arc Miracle Box, +and Mystery Box history pages from captured UDP data. It also captures tracked +achievements during login, including completed and in-progress records. Pull +history is written as tracker-ready JSON and achievements use a separate +versioned JSON format. > [!NOTE] -> The import JSON contains decoded history rows and the shareable NTE user UID when it can be detected. It does **not** export tokens, account IDs, role IDs, device IDs, server IPs, raw packets, cookies, session data, or other capture metadata. +> Export JSON contains decoded history or achievement records and the shareable +> NTE user UID when it can be detected. It does **not** export tokens, account +> IDs, role IDs, device IDs, server IPs, raw packets, cookies, session data, or +> other capture metadata. ## Requirements @@ -90,7 +97,11 @@ From source: Or simply double-click **`run-exporter.cmd`**. > [!IMPORTANT] -> For automatic user UID and account-server detection, launch the tool **before pressing Start on the game's main menu**. If you are already in game, history capture can still work; the tool will ask for missing account details before saving. +> Start the exporter **before pressing Start on the game's main menu**. This is +> required for achievement capture and enables automatic user UID and +> account-server detection. If already in game, pull-history capture still +> works, but achievements require returning to the main menu and logging in +> again; missing account details are requested before saving. Once running, open any supported history board in game. The tool keeps listening until you press any key. After it prints and saves the results, press any key again to close the exporter. Exports are written under `exports\` as: @@ -98,6 +109,7 @@ Once running, open any supported history board in game. The tool keeps listening - `_Limited_.json` - `_Arc_.json` - `_MysteryBox_.json` +- `_Achievements_.json` If the user UID is not detected automatically, the console asks for it before saving. Leaving it blank saves as `unknown__.json`, but may prevent import on some trackers. @@ -110,6 +122,25 @@ Exports are not copied to the clipboard by default. Add `--copy-clipboard` to co If a page response is missed, the exporter reports the missing page number while capture is still running. Leave the exporter open, close and reopen that history board, then scroll down again. Scrolling backward within the existing view does not request the cached pages again. The replacement capture is accepted and the tool confirms when the gap has been recovered. If reopening the board still produces no page messages, return to the main menu and re-enter the game to start a fresh connection. +### Achievement export + +Start the live exporter before pressing Start on the game's main menu. The +tracked achievement state is loaded during login, captured automatically, and written to +`exports/_Achievements_*.json` as a versioned achievement export with +account metadata, completion totals, and records grouped by achievement category. +Both completed and in-progress achievements are included with their current +progress, status, and completion time when available. Generated metadata adds +the localized title and description, target, quality, and rewards from the +current `NTE_Assets` achievement table. Newly captured IDs that are not yet in +the bundled mapping still export normally; only their optional display metadata +is omitted until the mappings are refreshed. +PlayStation trophy entries are retained and identified separately +even though they are not shown in the in-game achievement list. The console +reports completed and in-progress totals for in-game and PlayStation records as +soon as the list has been decoded. Every observed record without a completion +timestamp is classified as `in_progress`, even when its numeric progress is +zero; compound achievements can track hidden checklist progress this way. + #### Linux/macOS Downloaded executable, using `sudo` when your system requires elevated capture permission: @@ -150,7 +181,7 @@ Decodes a `mitmproxy .flows` capture instead of listening live — used for rese | Flag | Effect | | --------- | --------------------------------------------------- | -| `--live` | Capture live UDP traffic instead of reading a file. | +| `--live` | Capture live traffic, including achievements loaded at login. | | `--debug` | Also write the research CSV and privacy-safe capture diagnostics. | | `--user-uid ` | Override the auto-detected NTE user UID in the JSON export. | | `--copy-clipboard` | Copy a single live export JSON to clipboard after saving. | @@ -175,22 +206,22 @@ The exporter automatically includes the shareable NTE user UID when it appears i ## Mapping maintenance -Reward metadata can be rebuilt directly from the latest NTE_Assets tables and -English translation files. The reward snapshot may change IDs while UID inputs -remain untouched: +Reward and achievement metadata can be rebuilt directly from the latest +NTE_Assets tables and English translation files. Mapping snapshots may change +IDs while UID inputs remain untouched: -Run **Update reward mappings** from the GitHub Actions tab to generate and test +Run **Update mappings** from the GitHub Actions tab to generate and test the snapshot in a reviewable pull request, or run it locally: ```powershell python tools/update_mappings.py ``` -This rebuilds reviewable reward-map candidates directly from NTE_Assets under +This rebuilds reviewable mapping candidates directly from NTE_Assets under `build/mapping-update/`; committed mappings are untouched unless `--apply` is explicitly supplied. The snapshot may include additions, updates, and removals, while pool mappings and UID inputs remain untouched. See -[Reward mapping updates](docs/mapping-updates.md) for the source rules and +[Mapping updates](docs/mapping-updates.md) for the source rules and review workflow. ## Privacy diff --git a/docs/export-format.md b/docs/export-format.md index 6004738..cccc121 100644 --- a/docs/export-format.md +++ b/docs/export-format.md @@ -15,7 +15,7 @@ decoder-only offsets. "capture_source": "npcap", "exporter": { "name": "nte-history-exporter", - "version": "0.2.1" + "version": "0.3.0" }, "banner": { "id": "Lottery_Permanent", @@ -84,6 +84,12 @@ Current stable pool IDs: Avoid using `banner.name`, `reward_name`, `reward_type` or `reward_rank` as primary IDs. They are useful display fields, but may change when mapping files are updated. +Known `reward_id` values are matched case-insensitively and emitted using the +canonical casing from the bundled mapping. This prevents packet/asset casing +differences from dropping display metadata or producing different downstream +IDs. An unknown reward keeps the casing decoded from the packet. Reward IDs are +not UID inputs, so canonicalizing their casing does not change pull UIDs. + ## Stability notes Every JSON record is exported with a stable `uid`. Re-scanning deeper history can @@ -176,6 +182,79 @@ Mystery Box: } ``` +## Achievement export + +Achievements are written separately as +`_Achievements_.json`. The document contains only records +observed in the account's login response; definitions found only in the asset +mapping are not synthesized into the export. + +```json +{ + "format": "nte-achievement-export", + "format_version": 1, + "game": "Neverness to Everness", + "source": "live_capture", + "capture_source": "npcap", + "exporter": { + "name": "nte-history-exporter", + "version": "0.3.0" + }, + "scan": { + "in_game": { + "total_achievements": 367, + "completed_achievements": 310, + "in_progress_achievements": 57 + }, + "playstation": { + "total_achievements": 35, + "completed_achievements": 31, + "in_progress_achievements": 4 + } + }, + "user_uid": "218216016349", + "server_id": "23003", + "account_region": "EU", + "categories": { + "battle": [ + { + "id": "Battle_25", + "platform": "in_game", + "status": "in_progress", + "progress": 20, + "completed": false, + "completed_at": null, + "name": "Devil Within II", + "description": "Trigger Hexed ×50.", + "quality": "high", + "target": 50, + "rewards": [ + {"item_id": "Annulith", "amount": 10} + ] + } + ] + } +} +``` + +Achievement fields: + +- `scan.in_game` and `scan.playstation` separate records visible in game from + PlayStation trophy records and report completed/in-progress totals. +- `categories` groups records by the prefix of their captured ID. The category + is therefore not repeated on every record. +- `status` is `completed` when completion ticks are present and `in_progress` + otherwise. An observed compound achievement can be in progress while its + numeric `progress` is zero because individual checklist state is server-side. +- `completed_at` is a decoded UTC time for completed records and `null` for + in-progress records. +- `name`, `description`, `quality`, `target`, and `rewards` are optional display + metadata from `mappings/achievements.json`. +- A newly captured ID missing from the bundled mapping still exports its core + captured fields; only optional display metadata is omitted. +- Asset definitions absent from the network response are not exported because + absence cannot distinguish unstarted, locked, or unavailable content. + ## CSV diagnostics CSV exports are mainly for debugging the decoder. Tools should prefer JSON diff --git a/docs/limitations.md b/docs/limitations.md index bfd5c13..0054239 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -21,5 +21,13 @@ Known limitations: - The file adapter reads mitmproxy `.flows` captures for research and testing. - Npcap is Windows-only and is not redistributed with this project; Linux and macOS use their system libpcap. - Reward keys decode to their reward id string, so unknown rewards still export a usable `reward_id`; display names/ranks come from the mapping JSON files and should be expanded as new rewards appear. +- Achievement exports contain only records observed in the login response. + Asset-only definitions are not synthesized because absence cannot distinguish + unstarted, locked, or unavailable content. Unknown captured achievement IDs + still export without optional display metadata. +- Observed achievements have only `completed` and `in_progress` states. Some + compound achievements expose numeric progress `0` while the server tracks + hidden checklist progress, so every observed record without completion ticks + is classified as `in_progress`. Privacy guardrail: raw packet data must not be included in sanitized exports. diff --git a/docs/mapping-updates.md b/docs/mapping-updates.md index aa67d25..80c7ae4 100644 --- a/docs/mapping-updates.md +++ b/docs/mapping-updates.md @@ -1,11 +1,12 @@ -# Reward mapping updates +# Mapping updates -`tools/update_mappings.py` rebuilds `arcs.json`, `characters.json`, and -`items.json` directly from the current +`tools/update_mappings.py` rebuilds `arcs.json`, `characters.json`, +`items.json`, and `achievements.json` directly from the current [`Waifus-Grace/NTE_Assets`](https://github.com/Waifus-Grace/NTE_Assets) tables. -English display names come from `Localization/en/game.json`. The data tables +English display names and achievement descriptions come from +`Localization/en/game.json`. The data tables provide localization namespace/key references and quality metadata; their embedded `LocalizedString` values are deliberately ignored. @@ -18,15 +19,17 @@ game whose asset tables evolve over time. ### GitHub Actions -Run **Update reward mappings** from the repository's Actions tab. The optional -`source_ref` input accepts an NTE_Assets branch, tag, or commit and defaults to -`main`. +Run **Update mappings** from the repository's Actions tab. **Use workflow +from** selects the exporter branch whose updater is run and the target branch +for the resulting pull request. The `source_ref` input separately selects an +NTE_Assets branch, tag, or commit and defaults to `main`. The workflow rebuilds the mappings, runs the complete test suite, uploads the JSON review report, and opens a pull request from -`dev/automated-mapping-update`. Running it again updates the same open pull -request. If the authoritative snapshot has not changed, it reports zero changes -and does not create a commit or pull request. +`dev/automated-mapping-update-`. Running it again for the same +selected branch updates that branch's open pull request. If the authoritative +snapshot has not changed, it reports zero changes and does not create a commit +or pull request. The repository setting **Allow GitHub Actions to create and approve pull requests** must be enabled for automatic pull-request creation. The workflow @@ -54,7 +57,7 @@ To use an existing local checkout without network access: python tools/update_mappings.py --assets-root path\to\NTE_Assets ``` -The default output is `build/mapping-update/` and contains the three candidate +The default output is `build/mapping-update/` and contains the four candidate mapping files plus `mapping-update-report.json`. The report lists additions, updates, and deletions by file and records SHA-256 hashes for every source table. @@ -67,15 +70,21 @@ python -m pytest -q ``` For CI, `--check` exits with status 1 whenever the generated snapshot differs -from the committed reward mappings. It still writes the staged artifacts. +from the committed mappings. It still writes the staged artifacts. ## Source rules +- Every achievement in `DT_AchievementConfigInfo` is emitted to + `achievements.json` with its localized title and description, target, + category, quality, and rewards. - Every Arc in `DT_ForkItemData` is emitted to `arcs.json`. - Every character in `DT_Character` is emitted to `characters.json`. - Other pull rewards are selected by `GachaIllustrate` and resolved to the inventory tables for quality and localization keys. Appearance-table IDs provide canonical casing for glider rewards. +- Vehicle liveries are resolved from their illustration name and icon. Rows + whose icon identifies a different reward are treated as unreleased upstream + placeholders and skipped until the assets are corrected. - Mystery Box rewards are selected from every row in `DT_GashaponLotteryGlobal`. No event IDs are hard-coded, so future rotations are included automatically. Rewards resolve through the inventory, capital, @@ -91,12 +100,12 @@ from the committed reward mappings. It still writes the staged artifacts. ## UID compatibility boundary -This updater reads and writes only the three reward mapping files. It never +This updater reads and writes only the generated reward and achievement mapping files. It never reads or writes the permanent, limited, beginner, or Arc pool mapping files. Banner IDs, timestamps, record ordering, format version, and all UID inputs are therefore unchanged by mapping synchronization. -Reward IDs and display metadata may change or disappear when the authoritative -NTE_Assets snapshot changes. Existing exported history files remain unchanged; -new exports describe rewards using the current asset snapshot. UID stability is -independent of reward display mappings. +Reward and achievement IDs or display metadata may change or disappear when +the authoritative NTE_Assets snapshot changes. Existing exports remain +unchanged; new exports use the current asset snapshot. UID stability is +independent of display mappings. diff --git a/docs/packet-format.md b/docs/packet-format.md index de1c643..ad8937f 100644 --- a/docs/packet-format.md +++ b/docs/packet-format.md @@ -1,6 +1,21 @@ # Packet Format Notes -This prototype supports separate Monopoly, Arc/Gashapon, and Mystery Box history decoders. +This prototype supports separate Monopoly, Arc/Gashapon, Mystery Box history, +and achievement decoders. + +## Achievements + +The account's achievement component arrives in an inbound TCP game-data block +during login. The block is LZ4-decoded and contains `AchievementRecord` entries. +Each record provides an achievement ID, an unsigned numeric progress value, and +a .NET completion timestamp. A non-zero timestamp marks a completed record. +Every observed record without completion ticks is treated as in progress; +compound achievements can have server-side checklist progress while exposing a +numeric value of zero. + +Only records present in the network response are exported. Display metadata is +looked up case-insensitively in `mappings/achievements.json`, because captured +and asset-table ID casing can differ. Missing metadata never prevents export. ## Decoder strategy @@ -86,6 +101,10 @@ Decoded fields: Arc/Gashapon history uses the same scheme at `ASCII * 2`. - The decoder decodes the key to its id string and looks up display metadata in `mappings/arcs.json`, `mappings/characters.json`, and `mappings/items.json`. + Lookups are case-insensitive across Monopoly, Arc, and Mystery Box decoders; + known IDs are emitted using mapping-canonical casing. + Achievement display metadata is generated separately in + `mappings/achievements.json`. Unknown rewards still export their decoded id with empty name/rank. Page and row numbers are research metadata only. They must not be used for permanent dedupe because they shift when new history appears. diff --git a/mappings/achievements.json b/mappings/achievements.json new file mode 100644 index 0000000..0b91b58 --- /dev/null +++ b/mappings/achievements.json @@ -0,0 +1,450 @@ +{ + "battle_10": {"name": "Here It Comes! V", "description": "Use Ultimate ×300.", "category": "fight", "quality": "execellent", "target": 300, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "battle_11": {"name": "Speed Above All I", "description": "Trigger Critical Dodge ×10.", "category": "fight", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_12": {"name": "Speed Above All II", "description": "Trigger Critical Dodge ×50.", "category": "fight", "quality": "normal", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_13": {"name": "Speed Above All III", "description": "Trigger Critical Dodge ×100.", "category": "fight", "quality": "normal", "target": 100, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_14": {"name": "Speed Above All IV", "description": "Trigger Critical Dodge ×200.", "category": "fight", "quality": "high", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "battle_15": {"name": "Speed Above All V", "description": "Trigger Critical Dodge ×300.", "category": "fight", "quality": "execellent", "target": 300, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "battle_16": {"name": "Crumbles at Touch I", "description": "In battle, Break enemies ×5.", "category": "fight", "quality": "normal", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_17": {"name": "Crumbles at Touch II", "description": "In battle, Break enemies ×10.", "category": "fight", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_18": {"name": "Crumbles at Touch III", "description": "In battle, Break enemies ×50.", "category": "fight", "quality": "normal", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_19": {"name": "Crumbles at Touch IV", "description": "In battle, Break enemies ×100.", "category": "fight", "quality": "high", "target": 100, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "battle_20": {"name": "Crumbles at Touch V", "description": "In battle, Break enemies ×200.", "category": "fight", "quality": "execellent", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "battle_21": {"name": "Harvest Season I", "description": "Trigger Blossom ×10.", "category": "fight", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_22": {"name": "Harvest Season II", "description": "Trigger Blossom ×50.", "category": "fight", "quality": "high", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "battle_23": {"name": "Harvest Season III", "description": "Trigger Blossom ×200.", "category": "fight", "quality": "execellent", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "battle_24": {"name": "Devil Within I", "description": "Trigger Hexed ×10.", "category": "fight", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_25": {"name": "Devil Within II", "description": "Trigger Hexed ×50.", "category": "fight", "quality": "high", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "battle_26": {"name": "Devil Within III", "description": "Trigger Hexed ×200.", "category": "fight", "quality": "execellent", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "battle_27": {"name": "Stopped Thinking I", "description": "Trigger Remora ×10.", "category": "fight", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_28": {"name": "Stopped Thinking II", "description": "Trigger Remora ×50.", "category": "fight", "quality": "high", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "battle_29": {"name": "Stopped Thinking III", "description": "Trigger Remora ×200.", "category": "fight", "quality": "execellent", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "battle_30": {"name": "Death Nova I", "description": "Trigger Nova ×10.", "category": "fight", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_31": {"name": "Death Nova II", "description": "Trigger Nova ×50.", "category": "fight", "quality": "high", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "battle_32": {"name": "Death Nova III", "description": "Trigger Nova ×200.", "category": "fight", "quality": "execellent", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "battle_33": {"name": "Scarlet Corruption I", "description": "Trigger Scorch ×10.", "category": "fight", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_34": {"name": "Scarlet Corruption II", "description": "Trigger Scorch ×50.", "category": "fight", "quality": "high", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "battle_35": {"name": "Scarlet Corruption III", "description": "Trigger Scorch ×200.", "category": "fight", "quality": "execellent", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "battle_36": {"name": "Soul Color I", "description": "Trigger Stain ×10.", "category": "fight", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_37": {"name": "Soul Color II", "description": "Trigger Stain ×50.", "category": "fight", "quality": "high", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "battle_38": {"name": "Soul Color III", "description": "Trigger Stain ×200.", "category": "fight", "quality": "execellent", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "battle_39": {"name": "Bumper Harvest I", "description": "Trigger Charge ×10.", "category": "fight", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_40": {"name": "Bumper Harvest II", "description": "Trigger Charge ×50.", "category": "fight", "quality": "high", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "battle_41": {"name": "Bumper Harvest III", "description": "Trigger Charge ×200.", "category": "fight", "quality": "execellent", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "battle_42": {"name": "Malevolence Unbound I", "description": "Trigger Discord ×10.", "category": "fight", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_43": {"name": "Malevolence Unbound II", "description": "Trigger Discord ×50.", "category": "fight", "quality": "high", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "battle_44": {"name": "Malevolence Unbound III", "description": "Trigger Discord ×200.", "category": "fight", "quality": "execellent", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "battle_49": {"name": "Variation Movement I", "description": "Cast Skill ×10.", "category": "fight", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_50": {"name": "Variation Movement II", "description": "Cast Skill ×50.", "category": "fight", "quality": "normal", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_51": {"name": "Variation Movement III", "description": "Cast Skill ×100.", "category": "fight", "quality": "normal", "target": 100, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_52": {"name": "Variation Movement IV", "description": "Cast Skill ×200.", "category": "fight", "quality": "high", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "battle_53": {"name": "Variation Movement V", "description": "Cast Skill ×300.", "category": "fight", "quality": "execellent", "target": 300, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "battle_54": {"name": "Death is the Final Movement of All Life I", "description": "Defeat enemies with Ultimate ×10.", "category": "fight", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_55": {"name": "Death is the Final Movement of All Life II", "description": "Defeat enemies with Ultimate ×50.", "category": "fight", "quality": "high", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "battle_56": {"name": "Death is the Final Movement of All Life III", "description": "Defeat enemies with Ultimate ×200.", "category": "fight", "quality": "execellent", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "battle_6": {"name": "Here It Comes! I", "description": "Use Ultimate ×10.", "category": "fight", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_63": {"name": "Come At Me!", "description": "In battle, have a single character with a Shield of over 5,000 points.", "category": "fight", "quality": "normal", "target": 5000, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_64": {"name": "Non-Lethal Defense", "description": "In battle, have a single character with a Shield of over 10,000 points.", "category": "fight", "quality": "high", "target": 10000, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "battle_65": {"name": "Absolute Defense", "description": "In battle, have a single character with a Shield of over 30,000 points.", "category": "fight", "quality": "execellent", "target": 30000, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "battle_7": {"name": "Here It Comes! II", "description": "Use Ultimate ×50.", "category": "fight", "quality": "normal", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_8": {"name": "Here It Comes! III", "description": "Use Ultimate ×100.", "category": "fight", "quality": "normal", "target": 100, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "battle_9": {"name": "Here It Comes! IV", "description": "Use Ultimate ×200.", "category": "fight", "quality": "high", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_1": {"name": "Hethereau Beginner", "description": "Raise Hunter Level to Lvl 10.", "category": "develop", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_10": {"name": "One-Man Army IV", "description": "Raise 1 character to Lvl 80.", "category": "develop", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_11": {"name": "Four-Man Squad I", "description": "Raise 4 characters to Lvl 20.", "category": "develop", "quality": "normal", "target": 4, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_12": {"name": "Four-Man Squad II", "description": "Raise 4 characters to Lvl 40.", "category": "develop", "quality": "normal", "target": 4, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_13": {"name": "Four-Man Squad III", "description": "Raise 4 characters to Lvl 60.", "category": "develop", "quality": "high", "target": 4, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_14": {"name": "Four-Man Squad IV", "description": "Raise 4 characters to Lvl 80.", "category": "develop", "quality": "execellent", "target": 4, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "develop_15": {"name": "There are seven more fighters just as tough as me!", "description": "Raise 8 characters to Lvl 80.", "category": "develop", "quality": "execellent", "target": 8, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "develop_16": {"name": "Touching the Forbidden Edge I", "description": "Raise 1 Arc to Lvl 20.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_17": {"name": "Touching the Forbidden Edge II", "description": "Raise 1 Arc to Lvl 40.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_18": {"name": "Touching the Forbidden Edge III", "description": "Raise 1 Arc to Lvl 60.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_19": {"name": "Touching the Forbidden Edge IV", "description": "Raise 1 Arc to Lvl 80.", "category": "develop", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_2": {"name": "The Master Shows the Way…", "description": "Raise Hunter Level to Lvl 20.", "category": "develop", "quality": "normal", "target": 20, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_20": {"name": "Duel Master I", "description": "Raise 4 Arcs to Lvl 20.", "category": "develop", "quality": "normal", "target": 4, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_21": {"name": "Duel Master II", "description": "Raise 4 Arcs to Lvl 40.", "category": "develop", "quality": "normal", "target": 4, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_22": {"name": "Duel Master III", "description": "Raise 4 Arcs to Lvl 60.", "category": "develop", "quality": "high", "target": 4, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_23": {"name": "Duel Master IV", "description": "Raise 4 Arcs to Lvl 80.", "category": "develop", "quality": "execellent", "target": 4, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "develop_24": {"name": "Duel Master V", "description": "Raise 8 Arcs to Lvl 80.", "category": "develop", "quality": "execellent", "target": 8, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "develop_25": {"name": "The Nameless", "description": "Upgrade 1 Orange Console Module to Lvl 10.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_26": {"name": "Stick Fighter", "description": "Upgrade 1 Orange Console Module to Lvl 20.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_27": {"name": "Half-Hearted Devotion", "description": "Upgrade 6 Orange Console Modules to Lvl 10.", "category": "develop", "quality": "normal", "target": 6, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_28": {"name": "Retro King", "description": "Upgrade 6 Orange Console Modules to Lvl 20.", "category": "develop", "quality": "high", "target": 6, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_29": {"name": "Hardcore Gamer", "description": "Upgrade 1 Orange Console Cartridge to Lvl 10.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_3": {"name": "Ultra-Spec Growth", "description": "Raise Hunter Level to Lvl 30.", "category": "develop", "quality": "high", "target": 30, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_30": {"name": "Platinum Trophy", "description": "Upgrade 1 Orange Console Cartridge to Lvl 20.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_31": {"name": "Digital Collectibles", "description": "Upgrade 3 Orange Console Cartridges to Lvl 10.", "category": "develop", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_32": {"name": "World Dominator!", "description": "Upgrade 3 Orange Console Cartridges to Lvl 20.", "category": "develop", "quality": "high", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_33": {"name": "Definition of Power I", "description": "Upgrade any character's Skill ×5.", "category": "develop", "quality": "normal", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_34": {"name": "Definition of Power II", "description": "Upgrade any character's Skill ×20.", "category": "develop", "quality": "high", "target": 20, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_35": {"name": "Definition of Power III", "description": "Upgrade any character's Skill ×50.", "category": "develop", "quality": "execellent", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "develop_36": {"name": "Final Turn I", "description": "Upgrade any character's Ultimate level ×5.", "category": "develop", "quality": "normal", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_37": {"name": "Final Turn II", "description": "Upgrade any character's Ultimate level ×20.", "category": "develop", "quality": "high", "target": 20, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_38": {"name": "Final Turn III", "description": "Upgrade any character's Ultimate level ×50.", "category": "develop", "quality": "execellent", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "develop_39": {"name": "Consecutive Normal Punches I", "description": "Upgrade any character's Basic Attack ×5.", "category": "develop", "quality": "normal", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_4": {"name": "I Haven't Reached My Limit Yet", "description": "Raise Hunter Level to Lvl 40.", "category": "develop", "quality": "high", "target": 40, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_40": {"name": "Consecutive Normal Punches II", "description": "Upgrade any character's Basic Attack ×20.", "category": "develop", "quality": "high", "target": 20, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_41": {"name": "Consecutive Normal Punches III", "description": "Upgrade any character's Basic Attack ×50.", "category": "develop", "quality": "execellent", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "develop_42": {"name": "Invited Guest", "description": "Obtain a total of 5 characters.", "category": "develop", "quality": "normal", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_43": {"name": "Travel Companions", "description": "Obtain a total of 10 characters.", "category": "develop", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_44": {"name": "Careful Maintenance", "description": "Obtain 1 Arc.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_45": {"name": "Fine Collection", "description": "Obtain a total of 10 Arcs.", "category": "develop", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_46": {"name": "True Connoisseur", "description": "Obtain a total of 50 Arcs.", "category": "develop", "quality": "normal", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_47": {"name": "Glory Hunter", "description": "Obtain a total of 100 Arcs.", "category": "develop", "quality": "high", "target": 100, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_48": {"name": "Coins Keep Rolling In", "description": "Obtain a total of 100,000 Beetle Coins.", "category": "develop", "quality": "normal", "target": 100000, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_49": {"name": "Big Spender", "description": "Obtain a total of 500,000 Beetle Coins.", "category": "develop", "quality": "normal", "target": 500000, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_5": {"name": "SSS-Class Anomaly Conqueror", "description": "Raise Hunter Level to Lvl 50.", "category": "develop", "quality": "execellent", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "develop_50": {"name": "Walking Collector", "description": "Obtain a total of 1,000,000 Beetle Coins.", "category": "develop", "quality": "normal", "target": 1000000, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_51": {"name": "Entomologist", "description": "Obtain a total of 10,000,000 Beetle Coins.", "category": "develop", "quality": "high", "target": 10000000, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_52": {"name": "Luck and Strategy Combined", "description": "Make 10 rolls.", "category": "develop", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_53": {"name": "Dice Never Leave Your Hand", "description": "Make 100 rolls.", "category": "develop", "quality": "high", "target": 100, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "develop_54": {"name": "Fate in Your Hands a Thousand Times", "description": "Make 1,000 rolls.", "category": "develop", "quality": "execellent", "target": 1000, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "develop_6": {"name": "I Acknowledge You as the Strongest", "description": "Raise Hunter Level to Lvl 60.", "category": "develop", "quality": "execellent", "target": 60, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "develop_7": {"name": "One-Man Army I", "description": "Raise 1 character to Lvl 20.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_8": {"name": "One-Man Army II", "description": "Raise 1 character to Lvl 40.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "develop_9": {"name": "One-Man Army III", "description": "Raise 1 character to Lvl 60.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "explore_1": {"name": "Oraoraoraoraora!", "description": "Complete Anomaly Commission: Backstreet Boxer", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_10": {"name": "Where Did My Inspiration Go?!", "description": "Complete Anomaly Commission: Inspiration Terminator", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_11": {"name": "Red Light, Green Light", "description": "Complete Anomaly Commission: After School", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_12": {"name": "Rain's Lament", "description": "Complete Anomaly Commission: Prepare for Rain", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_13": {"name": "Pop Art", "description": "Complete Anomaly Commission: Lightspeed Tagger", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_14": {"name": "Cherry Blush Returns to Evening White", "description": "Complete Anomaly Commission: Crimson Hexblade", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_15": {"name": "It's starting to clear up now!", "description": "Complete Anomaly Commission: Dragon Gate Substitute", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_16": {"name": "Blackjack I", "description": "Open 1 \"21's Gift.\"", "category": "explore", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "explore_17": {"name": "Blackjack II", "description": "Open 10 \"21's Gifts.\"", "category": "explore", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "explore_18": {"name": "Blackjack III", "description": "Open 50 \"21's Gifts.\"", "category": "explore", "quality": "normal", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "explore_19": {"name": "33729 I", "description": "Unlock 1 \"ReroRero Phone Booth.\"", "category": "explore", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "explore_2": {"name": "Muda Muda Muda!", "description": "Complete Anomaly Commission: Rooftop Boxer", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_20": {"name": "33729 II", "description": "Unlock 5 \"ReroRero Phone Booths.\"", "category": "explore", "quality": "normal", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "explore_21": {"name": "33729 III", "description": "Unlock 10 \"ReroRero Phone Booths.\"", "category": "explore", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "explore_22": {"name": "Orichalcum Cage", "description": "Reach Divination Lvl 3.", "category": "explore", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "explore_23": {"name": "Mithiril Cage", "description": "Reach Divination Lvl 5.", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_24": {"name": "Adamant Cage", "description": "Reach Divination Lvl 10.", "category": "explore", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "explore_25": {"name": "Skyline I", "description": "Unlock a total of 1 Wertheimer Tower.", "category": "explore", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "explore_26": {"name": "Skyline II", "description": "Unlock a total of 5 Wertheimer Towers.", "category": "explore", "quality": "normal", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "explore_27": {"name": "Urban Life Starting from Zero", "description": "Log in for 1 day in total", "category": "explore", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "explore_28": {"name": "Day Seven", "description": "Log in for 7 days in total", "category": "explore", "quality": "normal", "target": 7, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "explore_29": {"name": "New Hethereau Resident", "description": "Log in for 30 days in total", "category": "explore", "quality": "high", "target": 30, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_3": {"name": "Silent Testimony", "description": "Complete Anomaly Commission: Case of Serial Explosion", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_30": {"name": "Tow Shadow", "description": "Complete Anomaly Commission: Invasion! Octopus Trailer!", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_31": {"name": "Ghost of Whitetail Ridge", "description": "Complete Anomaly Commission: Ghost of Whitetail Ridge", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_32": {"name": "Extreme Rally Fanatic", "description": "Complete Anomaly Commission: Ghost Boulevard Wild Rally", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_33": {"name": "Beep, boop boop! Beep boop beep!", "description": "Complete Anomaly Commission: Beat King", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_34": {"name": "Little Bear's Patchwork", "description": "Complete Anomaly Commission: Sad Bears Don't Cry", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_35": {"name": "Quickcharge Compatible", "description": "Complete Anomaly Commission: Power Saver", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_36": {"name": "Night of K.O.", "description": "Complete Anomaly Commission: Party Boxer", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_37": {"name": "ONE ON ONE!", "description": "Complete Anomaly Commission: Court Boxer", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_38": {"name": "Never Stop Leaping", "description": "Complete Anomaly Commission: Heaven's Forsaken", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_39": {"name": "Another round?", "description": "Complete Anomaly Commission: Wandering Puppets", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_4": {"name": "Snap! Don't Blink!", "description": "Complete Anomaly Commission: Brand Anniversary", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_40": {"name": "You Moved First!", "description": "Complete Anomaly Commission: Change Your Perspective?", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_41": {"name": "52 Hertz", "description": "Complete Anomaly Commission: Deep Blue Sorrow", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_42": {"name": "Reverse Mystery Box", "description": "Complete Anomaly Commission: Whose Leak Is This?", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_43": {"name": "What You See Is Real", "description": "Complete Anomaly Commission: This is not a painting", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_44": {"name": "New Colors", "description": "Complete Anomaly Commission: Plot Hole Repair Project", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_45": {"name": "Nightmare Begins", "description": "Reach an ending in Anomaly Commission: Nightmare-Bound", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_46": {"name": "Meow Meow Championship", "description": "Complete Anomaly Commission: Yarn Ball or Fons?", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_47": {"name": "Clear Weather?", "description": "Complete Anomaly Commission: Prayers for Clear Skies", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_48": {"name": "Let It Go", "description": "Complete Anomaly Commission: Clearing After Snowfall", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_49": {"name": "All Good Things Must Come to an End", "description": "Complete Anomaly Commission: Kindness Hard to Refuse", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_5": {"name": "Normal Summon!", "description": "Complete Anomaly Commission: C's Cube", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_50": {"name": "Into the Empty Nest", "description": "Complete Anomaly Commission: Call of the Lost", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_51": {"name": "Butterfly in a Jar", "description": "Complete Anomaly Commission: Let's Go See a Movie", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_54": {"name": "Awakening Moment", "description": "Complete Anomaly Commission: The Book of Post-Adolescence", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_55": {"name": "Cagebreaker", "description": "Complete the Anomaly Commission: Sunken Door", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_56": {"name": "Thus Spoke Sunya", "description": "Complete the Anomaly Commission: Fawn Alone", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_57": {"name": "Falling Petals", "description": "Complete the Anomaly Commission: Twin Blooms", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_58": {"name": "Lights Out", "description": "Complete the Anomaly Commission: Lantern Parade", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_59": {"name": "This Bear Will Lead!", "description": "Complete the Anomaly Commission: Storybook Skirmish", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_6": {"name": "Special Summon!", "description": "Complete Anomaly Commission: C's Tower", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_60": {"name": "Broken Refrain", "description": "Complete the Anomaly Commission: Finale: Da Capo", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_7": {"name": "Welcome!", "description": "Complete Anomaly Commission: Red Door", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_8": {"name": "KeepOut!!", "description": "Complete Anomaly Commission: Ride Assault", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "explore_9": {"name": "In books there lies…", "description": "Complete Anomaly Commission: Video Cemetery", "category": "explore", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_11": {"name": "Kiroumaru's Second Caretaker", "description": "Reach Sakiri Affection Lvl 5.", "category": "social", "quality": "high", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_12": {"name": "Eternal Home", "description": "Reach Sakiri Affection Lvl 10.", "category": "social", "quality": "execellent", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_14": {"name": "Friendly Sparring", "description": "Reach Daffodill Affection Lvl 5.", "category": "social", "quality": "high", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_15": {"name": "As Far as the Eye Can See", "description": "Reach Daffodill Affection Lvl 10.", "category": "social", "quality": "execellent", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_17": {"name": "Personal Encyclopedia", "description": "Reach Edgar Affection Lvl 5.", "category": "social", "quality": "high", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_18": {"name": "Clear Answer", "description": "Reach Edgar Affection Lvl 10.", "category": "social", "quality": "execellent", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_2": {"name": "The Combo Called Mint Chocolate", "description": "Reach Mint Affection Lvl 5.", "category": "social", "quality": "high", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_23": {"name": "Keep It Up, Key Player!", "description": "Reach Nanally Affection Lvl 5.", "category": "social", "quality": "high", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_24": {"name": "Coluccis' Irreplaceable Right Hand", "description": "Reach Nanally Affection Lvl 10.", "category": "social", "quality": "execellent", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_3": {"name": "The Wind That Lingers for You", "description": "Reach Mint Affection Lvl 10.", "category": "social", "quality": "execellent", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_32": {"name": "Me?", "description": "Give Mint gifts ×3.", "category": "social", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_33": {"name": "All for me?", "description": "Give Mint gifts ×10.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_34": {"name": "Best Buds", "description": "Give Baicang gifts ×3.", "category": "social", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_35": {"name": "\"Such Generosity!\"", "description": "Give Baicang gifts ×10.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_36": {"name": "Sunya Care Guide", "description": "Give Adler gifts ×3.", "category": "social", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_37": {"name": "Enlightenment Will Remember…", "description": "Give Adler gifts ×10.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_38": {"name": "Beware of Hungry Ghosts", "description": "Give Sakiri gifts ×3.", "category": "social", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_39": {"name": "Kiroumaru! No Eating!", "description": "Give Sakiri gifts ×10.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_40": {"name": "Grateful for Your Care", "description": "Give Daffodill gifts ×3.", "category": "social", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_41": {"name": "Sincere Intentions", "description": "Give Daffodill gifts ×10.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_42": {"name": "Will Treasure This…", "description": "Give Edgar gifts ×3.", "category": "social", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_43": {"name": "Treasured Memories", "description": "Give Edgar gifts ×10.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_46": {"name": "No B-Bribes Allowed!", "description": "Give Nanally gifts ×3.", "category": "social", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_47": {"name": "Gladly Accepted!", "description": "Give Nanally gifts ×10.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_5": {"name": "Slacking Buddy", "description": "Reach Baicang Affection Lvl 5.", "category": "social", "quality": "high", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_54": {"name": "Making Connections", "description": "Reach Affection Lvl 5 with 5 characters total.", "category": "social", "quality": "normal", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_55": {"name": "Social Butterfly", "description": "Reach Affection Lvl 5 with 10 characters total.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_56": {"name": "Affection Collector", "description": "Reach Affection Lvl 10 with 5 characters total.", "category": "social", "quality": "execellent", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_58": {"name": "Reliable Partner", "description": "Reach Skia Affection Lvl 5.", "category": "social", "quality": "high", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_59": {"name": "Part of the Routine", "description": "Reach Skia Affection Lvl 10.", "category": "social", "quality": "execellent", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_6": {"name": "All Prayers for You", "description": "Reach Baicang Affection Lvl 10.", "category": "social", "quality": "execellent", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_60": {"name": "Social Networking", "description": "Give Skia gifts ×3.", "category": "social", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_61": {"name": "Too Kind…", "description": "Give Skia gifts ×10.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_68": {"name": "Plans with You", "description": "Reach Hathor Affection Lvl 5.", "category": "social", "quality": "high", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_69": {"name": "Exclusive Channel", "description": "Reach Hathor Affection Lvl 10.", "category": "social", "quality": "execellent", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_70": {"name": "Rationality Offline", "description": "Give Hathor gifts ×3.", "category": "social", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_71": {"name": "Delivery of the \"Exception\"", "description": "Give Hathor gifts ×10.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_73": {"name": "Rose's Blessing", "description": "Reach Jiuyuan Affection Lvl 5.", "category": "social", "quality": "high", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_74": {"name": "Nightingale's Favor", "description": "Reach Jiuyuan Affection Lvl 10.", "category": "social", "quality": "execellent", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_75": {"name": "Do Not Feed the Riddler", "description": "Give Jiuyuan gifts ×3.", "category": "social", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_76": {"name": "Rose of the Lost", "description": "Give Jiuyuan gifts ×10.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_78": {"name": "Wanna Be Friends?", "description": "Reach Haniel Affection Lvl 5.", "category": "social", "quality": "high", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_79": {"name": "Sharing Earbuds", "description": "Reach Haniel Affection Lvl 10.", "category": "social", "quality": "execellent", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_8": {"name": "The Second Pillar", "description": "Reach Adler Affection Lvl 5.", "category": "social", "quality": "high", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_80": {"name": "Does this count as free?", "description": "Give Haniel gifts ×3.", "category": "social", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_81": {"name": "President of the Paranormal Studies Support Club", "description": "Give Haniel gifts ×10.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_88": {"name": "Within Paradise", "description": "Reach Fadia Affection Lvl 5.", "category": "social", "quality": "high", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_89": {"name": "Loving Gaze", "description": "Reach Fadia Affection Lvl 10.", "category": "social", "quality": "execellent", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_9": {"name": "Highest Priority Order", "description": "Reach Adler Affection Lvl 10.", "category": "social", "quality": "execellent", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_90": {"name": "Beware of Addiction", "description": "Give Fadia gifts ×3.", "category": "social", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_91": {"name": "Enjoy a Little More", "description": "Give Fadia gifts ×10.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_92": {"name": "Rice Ball Partner", "description": "Reach Chiz Affection Lvl 5.", "category": "social", "quality": "high", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_93": {"name": "Where My Heart Finds Peace", "description": "Reach Chiz Affection Lvl 10.", "category": "social", "quality": "execellent", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_94": {"name": "Exclusive Sales Rep", "description": "Give Chiz gifts ×3.", "category": "social", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_95": {"name": "More Precious Than Fons", "description": "Give Chiz gifts ×10.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_96": {"name": "Bond & Harmony", "description": "Reach Aurelia Affection Lvl 5.", "category": "social", "quality": "high", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "friendship_97": {"name": "Dearest Friend", "description": "Reach Aurelia Affection Lvl 10.", "category": "social", "quality": "execellent", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "friendship_98": {"name": "In This Moment", "description": "Gift Aurelia 3 times.", "category": "social", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "friendship_99": {"name": "Spring and Sea", "description": "Gift Aurelia 10 times.", "category": "social", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "interest_1": {"name": "Water Soluble", "description": "Collapse in water due to stamina depletion.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_10": {"name": "Fluffy Addiction", "description": "Pet small animals ×10.", "category": "interest", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_11": {"name": "Zootopia", "description": "Pet small animals ×50.", "category": "interest", "quality": "high", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "interest_12": {"name": "Just One More Pet… Please!", "description": "Pet small animals ×100.", "category": "interest", "quality": "execellent", "target": 100, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "interest_13": {"name": "Witness Journal", "description": "Complete the \"Photography\" series of easter egg quests.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_14": {"name": "Non-Intrusive Documentation", "description": "Complete the \"Bird Enthusiast Guide\" easter egg quest.", "category": "interest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "interest_15": {"name": "The Dart's Fantastic Journey", "description": "Complete the \"Find the Dart\" easter egg quest.", "category": "interest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "interest_16": {"name": "A Pack of Spicy Sticks Promise", "description": "Complete the \"Little Boy at the Vending Machine\" easter egg quest.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_17": {"name": "Fake Car Owner Chronicles", "description": "Complete the \"Car Enthusiast\" easter egg quest.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_18": {"name": "Photo Op with Anomalies", "description": "Complete the \"Anomaly Photographer\" series easter egg quests.", "category": "interest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "interest_19": {"name": "World Changer", "description": "Complete the \"The Look of Eclipse\" series easter egg quests.", "category": "interest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "interest_2": {"name": "Leap of Faith", "description": "Fall from the wrong height.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_21": {"name": "Broken Machine's Generosity", "description": "Complete the \"Broken Vending Machine\" easter egg quest.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_22": {"name": "Pets' Secret Cafeteria", "description": "Complete the \"Pet Shop Empty Bowl\" easter egg quest.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_23": {"name": "Honest Body, Lying Lips", "description": "Complete the \"Radio Listener\" easter egg quest.", "category": "interest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "interest_24": {"name": "Vehicle Destroyer", "description": "Complete the \"Vehicle Brothers\" easter egg quest.", "category": "interest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "interest_25": {"name": "Gangster Dreams", "description": "Complete the \"The Great Coluccis\" easter egg quest.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_26": {"name": "Next Time for Sure!", "description": "Complete the \"Deadline Pressure\" easter egg quest.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_27": {"name": "The Devoured Melody", "description": "Complete the \"Campgrounds - Careless Tourist\" easter egg quest.", "category": "interest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "interest_28": {"name": "Multi-Location Check-in Expert", "description": "Complete the \"Other World Tourist\" easter egg quest.", "category": "interest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "interest_29": {"name": "The Other Side of the Legend", "description": "Complete the \"Nanally Anomaly Rumors\" easter egg quest.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_3": {"name": "Fate's Calling", "description": "Perform divination 1 time.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_30": {"name": "Omnic Crisis…?", "description": "Complete the \"Robot Command Conflict\" easter egg quest.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_31": {"name": "Danzaburou Will Never Be Enslaved!", "description": "Complete the \"Danzaburou\" series easter egg quests.", "category": "interest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "interest_32": {"name": "Wind Walker", "description": "Experience a weather change for the first time.", "category": "interest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "interest_33": {"name": "The Whisker's Net Catches All", "description": "After summoning Officer Whisker using the Arc Hethereau's Keeper, get punished by the Whisker.", "category": "interest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "interest_34": {"name": "Stars Falling Like Rain", "description": "Witness a meteor shower.", "category": "interest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "interest_35": {"name": "365 Miles", "description": "Unlock the ReroRero Phone Booth on top of the Midas Arc Workshop.", "category": "interest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "interest_36": {"name": "The Sound of Music", "description": "Play the piano.", "category": "interest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "interest_37": {"name": "It's 100!", "description": "Roll 100 with dice.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_38": {"name": "I'll Be Back", "description": "Restart at the bonfire 10 times after defeat.", "category": "interest", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_39": {"name": "Mind the Hot Chocolate", "description": "Receive burn damage 33 times.", "category": "interest", "quality": "normal", "target": 33, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_4": {"name": "Worldline Convergence", "description": "Perform 10 Divinations.", "category": "interest", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "interest_40": {"name": "Hold Your Breath", "description": "Take Mold damage 33 times.", "category": "interest", "quality": "normal", "target": 33, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_41": {"name": "Nah, I'd Win", "description": "Fail \"Test Your Luck\" at the Lucky Fuzzy 33 times.", "category": "interest", "quality": "normal", "target": 33, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_42": {"name": "Pinky Promise", "description": "Gaze at the night sky with Linko at Hethereau Skytower, and make a pinky promise beneath the stars.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_5": {"name": "Omens of Impending Change", "description": "Perform 50 Divinations.", "category": "interest", "quality": "execellent", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "interest_6": {"name": "Mysterious Big Eater", "description": "Fill up the Hunger Bar ×5.", "category": "interest", "quality": "normal", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "interest_7": {"name": "Pac-man!", "description": "Fill up the Hunger Bar ×20.", "category": "interest", "quality": "high", "target": 20, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "interest_8": {"name": "The Art of Inhaling Food", "description": "Fill up the Hunger Bar ×50.", "category": "interest", "quality": "execellent", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "interest_9": {"name": "Can I Pet It!", "description": "Pet small animals 1 time.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_1": {"name": "Trying to Breathe in the Concrete Jungle", "description": "Upgrade Life Skills once.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_100": {"name": "Silent Fall", "description": "Encounter Daffodill training in Miguel District.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_101": {"name": "Hmm! Hmm! Observation Log", "description": "Encounter Edgar at Nacupeda Park, studying and copying Nacupeda.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_102": {"name": "Guardian of Order", "description": "Encounter Skia at the street-side bulletin board, checking the announcements.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_103": {"name": "Thoughtful Butler", "description": "Encounter Adler shopping for groceries at Origami Market.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_104": {"name": "A Glass of Water: Half Full", "description": "Obtain 1 \"Glass of Water\" at the auction.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_105": {"name": "Our Path", "description": "Obtain 1 \"Dream Path\" at the auction.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_106": {"name": "Who Says True Love Weighs You Down?", "description": "Obtain 1 \"Butterflies to Flowers\" at the auction.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_107": {"name": "The Kapé", "description": "Obtain 1 \"Celebrations to: Kapé\" at the auction.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_108": {"name": "Future Audience", "description": "Obtain 1 \"Azure Strings\" at the auction.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_109": {"name": "Because I Love Soccer", "description": "Obtain 1 \"Golden Flying Ball\" at the auction.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_13": {"name": "Business Power, Full Throttle!", "description": "Accumulate 5 stars in Active Tycoon Gameplay.", "category": "city", "quality": "normal", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_14": {"name": "Chosen by the Stars", "description": "Accumulate 10 stars in Active Tycoon Gameplay.", "category": "city", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Life_15": {"name": "Golden Age", "description": "Accumulate 20 stars in Active Tycoon Gameplay.", "category": "city", "quality": "execellent", "target": 20, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "Life_16": {"name": "Full of Energy!", "description": "Spend a total of 50 City Stamina.", "category": "city", "quality": "normal", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_17": {"name": "Burn Your Cosmos", "description": "Spend a total of 500 City Stamina.", "category": "city", "quality": "high", "target": 500, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Life_18": {"name": "High-End Business War", "description": "Spend a total of 2,000 City Stamina.", "category": "city", "quality": "execellent", "target": 2000, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "Life_19": {"name": "Mysterious Touch", "description": "Upgrade Anomaly Furniture once.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_2": {"name": "Player One", "description": "Upgrade Life Skills ×10.", "category": "city", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Life_20": {"name": "Connecting Hearts with Furniture", "description": "Upgrade Anomaly Furniture ×10.", "category": "city", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Life_21": {"name": "Advanced Revelations", "description": "Upgrade Anomaly Furniture ×50.", "category": "city", "quality": "execellent", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "Life_22": {"name": "Going Once, Going Twice", "description": "Participate in 1 auction.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_23": {"name": "Bidding Fanatic", "description": "Participate in 10 auctions.", "category": "city", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Life_24": {"name": "Everything Has a Price", "description": "Participate in 100 auctions.", "category": "city", "quality": "execellent", "target": 100, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "Life_3": {"name": "Max Out City Life!", "description": "Upgrade Life Skills ×50.", "category": "city", "quality": "execellent", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "Life_31": {"name": "Unit-01", "description": "Obtain 1 vehicle.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_32": {"name": "Mobile Trio", "description": "Obtain 3 vehicles.", "category": "city", "quality": "high", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Life_33": {"name": "The Seven", "description": "Obtain 7 vehicles.", "category": "city", "quality": "execellent", "target": 7, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "Life_4": {"name": "Want Some Beans?", "description": "Unlock 1 coffee shop.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_5": {"name": "Coffee Connoisseur in Training", "description": "Unlock 3 coffee shops.", "category": "city", "quality": "high", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Life_6": {"name": "Regional Flavor Master", "description": "Unlock 5 coffee shops.", "category": "city", "quality": "execellent", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "Life_67": {"name": "Real Estate Qualified", "description": "Acquire your first house.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_68": {"name": "Dual Residence Plan", "description": "Obtain a total of 2 houses.", "category": "city", "quality": "high", "target": 2, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Life_69": {"name": "Life Winner", "description": "Obtain a total of 4 houses.", "category": "city", "quality": "execellent", "target": 4, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "Life_70": {"name": "More Than Four Walls", "description": "Acquire furniture for the first time.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_71": {"name": "Storage Enthusiast", "description": "Obtain a total of 50 furniture.", "category": "city", "quality": "high", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Life_72": {"name": "The Room Comes Alive", "description": "Obtain a total of 100 furniture.", "category": "city", "quality": "execellent", "target": 100, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "Life_76": {"name": "Every Move is Magic", "description": "Win a prize from the claw machine once.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_77": {"name": "Plushie Maniac", "description": "Successfully grab prizes from the claw machine ×10.", "category": "city", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Life_78": {"name": "Time to Teach!", "description": "Successfully grab prizes from the claw machine ×100.", "category": "city", "quality": "high", "target": 100, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Life_79": {"name": "I Found The Fons by the Roadside", "description": "Turn in 10 Lost Items to the Security Office.", "category": "city", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_80": {"name": "Honest Finder", "description": "Turn in 50 Lost Items to the Security Office.", "category": "city", "quality": "high", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Life_81": {"name": "The Beacon of Humanity", "description": "Turn in 100 Lost Items to the Security Office.", "category": "city", "quality": "high", "target": 100, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Life_82": {"name": "Brave Passerby", "description": "Stop 10 illegal city activities.", "category": "city", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_83": {"name": "Whisker's Buddy", "description": "Stop 50 illegal city activities.", "category": "city", "quality": "high", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Life_84": {"name": "Embodiment of Justice", "description": "Stop 100 illegal city activities.", "category": "city", "quality": "execellent", "target": 100, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "Life_88": {"name": "\"At First, I Refused…\"", "description": "Find Mint learning Hair Care at the Pet Store.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_89": {"name": "Direct Hire", "description": "Find Nanally announcing the Coluccis' recruitment everywhere.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_90": {"name": "Bountiful Dreams", "description": "Find Nanally taking a nap.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_91": {"name": "Jelly Belly", "description": "Find Lacrimosa pondering how to carry large amounts of Tomato Jelly.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_92": {"name": "Once Upon a Dream", "description": "Find Lacrimosa fast asleep.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_93": {"name": "Expert Meets Expert", "description": "Find Hotori negotiating prices with the owner at Good Joe.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_94": {"name": "The Queen Wears Landric", "description": "Find Fadia returning from a shopping spree.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_95": {"name": "Speed Bows Down", "description": "Find Jiuyuan surrounded by Regalia service consultants.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_96": {"name": "New Energy Transport", "description": "Find Sakiri riding Kiroumaru around town.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_97": {"name": "Slacking Off Feels So Good", "description": "Find Baicang at Bagel eating his own melon.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_98": {"name": "Path of Balanced Efficiency", "description": "Encounter Hathor at the gas station after she just refueled.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Life_99": {"name": "You too?", "description": "Encounter Haniel cheering in the crowd at a Kokoro Rider show.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_1": {"name": "Mhm!", "description": "Receive Nacupeda's blessing.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_10": {"name": "Infinite Acceleration", "description": "Defeat the Meridian Racing Team.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_11": {"name": "Wind Barrier", "description": "Defeat the Tempestas Racing Team.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_12": {"name": "Uncrowned King", "description": "Defeat the Uncrowned King Racing Team.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_13": {"name": "Lightning Start", "description": "Win your first Racing PVP battle.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_14": {"name": "Speed Demon", "description": "Win 10 Racing PVP battles.", "category": "play", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_15": {"name": "King of Speed", "description": "Win 50 Racing PVP battles.", "category": "play", "quality": "execellent", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "Play_17": {"name": "Block Vanishing Act", "description": "Place 1st in Tetrominoes.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_18": {"name": "Space Management Master", "description": "Place 1st in Tetrominoes ×10.", "category": "play", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_19": {"name": "Less Is More", "description": "Place 1st in Tetrominoes ×30.", "category": "play", "quality": "execellent", "target": 30, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "Play_2": {"name": "Mhm! Mhm!", "description": "Receive 10 Nacupeda's blessings.", "category": "play", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_27": {"name": "First Pot of Gold", "description": "Collect 10,000 Fons in a single round of Pink Paws Heist.", "category": "play", "quality": "normal", "target": 10000, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_28": {"name": "Fons's Warudo", "description": "Collect 50,000 Fons in a single round of Pink Paws Heist.", "category": "play", "quality": "high", "target": 50000, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_29": {"name": "Ladon Approved", "description": "Collect 100,000 Fons in a single round of Pink Paws Heist.", "category": "play", "quality": "high", "target": 100000, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_3": {"name": "Mhm! Mhm! Mhm!", "description": "Receive 30 Nacupeda's blessings.", "category": "play", "quality": "high", "target": 30, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_30": {"name": "First Passenger", "description": "Drive a taxi to pick up 5 passengers.", "category": "play", "quality": "normal", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 5}, {"item_id": "Vehicle020_skin_1_Painting", "amount": 1}]}, + "Play_31": {"name": "Swift at Your Service", "description": "Drive a taxi to pick up 10 passengers.", "category": "play", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}, {"item_id": "Vehicle020_skin_1_CeilingLight", "amount": 1}]}, + "Play_32": {"name": "Have a Safe Trip", "description": "Drive a taxi to pick up 30 passengers.", "category": "play", "quality": "high", "target": 30, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_33": {"name": "First Order", "description": "Complete City Commission ×1.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_34": {"name": "Skilled Courier", "description": "Complete City Commission ×10.", "category": "play", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_35": {"name": "City Neighbors", "description": "Complete City Commission ×30.", "category": "play", "quality": "high", "target": 30, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_36": {"name": "Hethereau Shores", "description": "Catch a total of 50 fish in Sea Angler.", "category": "play", "quality": "normal", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_37": {"name": "Every Angler's Dream", "description": "Catch a total of 100 fish in Sea Angler.", "category": "play", "quality": "high", "target": 100, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_38": {"name": "Fishing God", "description": "Catch a total of 200 fish in Sea Angler.", "category": "play", "quality": "high", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_39": {"name": "Breakout Together", "description": "Use a special spot in the scene to leave the Detention Facility and successfully escape.", "category": "city", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_4": {"name": "Mhm…?", "description": "Receive Nacupeda's punishment for the first time.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_40": {"name": "Dawn in Detention", "description": "Survive the first round of detention shenanigans and discover one surprise… like finding your socks still match.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}, {"item_id": "PrisonCoin", "amount": 1}]}, + "Play_41": {"name": "Detention Strikes Back", "description": "Complete the next round of detention chaos and uncover two surprises—bonus points if nobody notices you napping.", "category": "city", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}, {"item_id": "PrisonCoin", "amount": 1}]}, + "Play_42": {"name": "Return of the Dawn", "description": "Master all the wacky encounters in detention and reap three unexpected rewards—because yes, even detention has its plot twists.", "category": "city", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}, {"item_id": "Fons", "amount": 10000}]}, + "Play_43": {"name": "Fight Club", "description": "Participate in Fight Club for the first time.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_44": {"name": "Hethereau Brawler King", "description": "Win the championship in Fight Club for the first time.", "category": "play", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}, {"item_id": "Title_PrisonFight_001", "amount": 1}]}, + "Play_45": {"name": "Rookie OP", "description": "Earn a total of 10,000 Praise Jams on Bagel.", "category": "play", "quality": "normal", "target": 10000, "rewards": [{"item_id": "Annulith", "amount": 5}, {"item_id": "Title_Bagel_001", "amount": 1}]}, + "Play_46": {"name": "Bagel Celebrity", "description": "Earn a total of 50,000 Praise Jams on Bagel.", "category": "play", "quality": "high", "target": 50000, "rewards": [{"item_id": "Annulith", "amount": 10}, {"item_id": "Title_Bagel_002", "amount": 1}]}, + "Play_47": {"name": "Influential User", "description": "Earn a total of 100,000 Praise Jams on Bagel.", "category": "play", "quality": "execellent", "target": 100000, "rewards": [{"item_id": "Annulith", "amount": 20}, {"item_id": "Title_Bagel_003", "amount": 1}]}, + "Play_48": {"name": "Stormwall", "description": "Defeat Lil' Nado on Easy Sparring.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_49": {"name": "Stormbreaker", "description": "Defeat Lil' Nado on Serious Match.", "category": "play", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_5": {"name": "Mhm? Mhm?", "description": "Receive 10 Nacupeda's punishments.", "category": "play", "quality": "high", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_50": {"name": "I Am the Storm", "description": "Defeat Lil' Nado on Final Bout.", "category": "play", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "Play_51": {"name": "Chest Master", "description": "Open 100 chests.", "category": "play", "quality": "high", "target": 100, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_54": {"name": "Appraise It!", "description": "Obtain 7 pieces of equipment with an appraisal value of 33.", "category": "play", "quality": "high", "target": 7, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_55": {"name": "Watch Your Step!", "description": "In the Chocolate Volcano, defeat 3 monsters using lava.", "category": "play", "quality": "high", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_56": {"name": "Continental Breakfast", "description": "Clear Red Mist Invasion, Hop Once: Cake Ace, Hop Twice: Cake Ace, Defend Against Bubbles, Leave None Standing, and Baaastille for the first time.", "category": "play", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_57": {"name": "I Choose You!", "description": "Hire Mercenary Fuzzy once.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_58": {"name": "Miracle Spike", "description": "Land one perfect spike once in Volley Star.", "category": "play", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_59": {"name": "No Continues", "description": "Clear all the stages in Volley Star.", "category": "play", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_6": {"name": "Mhm? Mhm? Mhm?!", "description": "Receive 30 Nacupeda's punishments.", "category": "play", "quality": "high", "target": 30, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_60": {"name": "Crowned Champions!", "description": "Win the championship in Volley Star's Championship mode.", "category": "play", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_61": {"name": "Court Conqueror", "description": "Reach No. 1 in the Volley Star World Rankings.", "category": "play", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_62": {"name": "Global Elite", "description": "Place in the top 10 of the Volley Star World Rankings.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_63": {"name": "Going Pro", "description": "Recruit 3 players in Volley Star.", "category": "play", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_64": {"name": "Full Roster", "description": "Recruit 7 players in Volley Star.", "category": "play", "quality": "high", "target": 7, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_65": {"name": "Maxed Out", "description": "Max out 1 player's talent in Volley Star.", "category": "play", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "Play_7": {"name": "End of Fun", "description": "Defeat the Sunday Racing Team.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_8": {"name": "Wild Mini Cars", "description": "Defeat the Coastal Drift Racing Team.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Play_9": {"name": "Lightning Speed: Dead End", "description": "Defeat the Full Speed Ahead Racing Team.", "category": "play", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_001": {"name": "Ultimate Anomaly Hunter III", "description": "Upgrade 1 character to Lvl 60.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_002": {"name": "Ultimate Anomaly Hunter II", "description": "Upgrade 1 character to Lvl 40.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_003": {"name": "Ultimate Anomaly Hunter I", "description": "Upgrade 1 character to Lvl 20.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_004": {"name": "Brawl III", "description": "Upgrade 1 Arc to Lvl 60.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_005": {"name": "Brawl II", "description": "Upgrade 1 Arc to Lvl 40.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_006": {"name": "Brawl I", "description": "Upgrade 1 Arc to Lvl 20.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_007": {"name": "Classic Clear", "description": "Upgrade 1 Orange Console Module to Lvl 20.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_008": {"name": "OG Legend", "description": "Upgrade 1 Orange Console Cartridge to Lvl 10.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_009": {"name": "Hyperdimensional Awakening", "description": "Increase Hunter Level to Lvl 35.", "category": "develop", "quality": "normal", "target": 35, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_010": {"name": "Rising Hunter", "description": "Increase Hunter Level to Lvl 25.", "category": "develop", "quality": "normal", "target": 25, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_011": {"name": "Flip-Flopping Future", "description": "Perform 10 Divinations.", "category": "interest", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_012": {"name": "33729", "description": "Unlock 5 ReroRero Phone Booths.", "category": "develop", "quality": "normal", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_013": {"name": "Mhm? Mhm? Mhm?!", "description": "Receive 30 Nacupeda's Punishments.", "category": "play", "quality": "normal", "target": 30, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_014": {"name": "Mhm! Mhm! Mhm!", "description": "Receive 30 Nacupeda's Blessings.", "category": "play", "quality": "normal", "target": 30, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_015": {"name": "Homecoming Day", "description": "Obtain your first house.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_016": {"name": "First Ride", "description": "Obtain 1 vehicle.", "category": "city", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_017": {"name": "Speed Above All", "description": "Trigger Critical Dodge ×200.", "category": "fight", "quality": "normal", "target": 200, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_018": {"name": "Life-Sim", "description": "Upgrade Life Skills ×10.", "category": "develop", "quality": "normal", "target": 10, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_019": {"name": "Beetle Coin Researcher", "description": "Obtain a total of 1,000,000 Beetle Coins.", "category": "develop", "quality": "normal", "target": 1000000, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_020": {"name": "Hit Me!", "description": "Open 21's Gift ×50.", "category": "develop", "quality": "normal", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_021": {"name": "Let's Start a Band!", "description": "Complete the A Legendary(?) Band's First Page series quests.", "category": "quest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_022": {"name": "Alea Jacta Est", "description": "Complete the Unforeseen Yet Foretold series quests.", "category": "quest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_023": {"name": "Ultimate Racer", "description": "Obtain 7 vehicles.", "category": "city", "quality": "normal", "target": 7, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_024": {"name": "Neon Master", "description": "Upgrade Life Skills ×50.", "category": "develop", "quality": "normal", "target": 50, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_025": {"name": "Beetle Bounty", "description": "Obtain a total of 10,000,000 Beetle Coins.", "category": "develop", "quality": "normal", "target": 10000000, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_026": {"name": "Ultimate Anomaly Hunter IV", "description": "Upgrade 1 character to Lvl 80.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_027": {"name": "Brawl IV", "description": "Upgrade 1 Arc to Lvl 80.", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_028": {"name": "Limits of 8-bit", "description": "Upgrade 3 Orange Console Cartridges to Lvl 20.", "category": "develop", "quality": "normal", "target": 3, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_029": {"name": "It's Snowing!", "description": "Complete the A Retrograde Poem for You series quests.", "category": "quest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_030": {"name": "Free Pass", "description": "Unlock a total of 5 Wertheimer Towers.", "category": "explore", "quality": "normal", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_031": {"name": "Fate's Echo", "description": "Reach Divination Lvl 10.", "category": "interest", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_032": {"name": "Lone Signal", "description": "Complete Anomaly Commission: Deep Blue Sorrow", "category": "develop", "quality": "normal", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_033": {"name": "It said go for it!", "description": "Defeat enemies with Ultimate ×500.", "category": "fight", "quality": "normal", "target": 500, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_034": {"name": "Affection Master", "description": "Reach Affection Lvl 10 with 5 characters total.", "category": "social", "quality": "normal", "target": 5, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "Playstation_035": {"name": "Variation Movement", "description": "Cast Redirect Skill ×300.", "category": "fight", "quality": "normal", "target": 300, "rewards": [{"item_id": "Annulith", "amount": 5}]}, + "quest_1": {"name": "Where Dreams Begin!", "description": "Complete the \"Unforeseen Yet Foretold\" series quests.", "category": "quest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "quest_10": {"name": "A Rainy Day in Hethereau", "description": "Complete the \"Weather Watch\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_11": {"name": "\"Win Friends and Influence People\"", "description": "Complete the \"Seeing and Believing\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_12": {"name": "The Wall Artist", "description": "Complete the \"Another Art on the Wall\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_13": {"name": "Alice's Baking Wonderland", "description": "Complete the \"Sweet Cakes, Bitter Cakes…\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_14": {"name": "Rules, Compliance, Containment", "description": "Complete the \"Deal? Deal!\" series quests.", "category": "quest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "quest_15": {"name": "Want to eat… Tomato Jelly", "description": "Complete the \"Tomato Jelly Rampage: Live Report!\" series quests.", "category": "quest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "quest_16": {"name": "Don't Stop Me Now!", "description": "Complete the \"Drift Away, Legend!\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_17": {"name": "Beep! Citizen ID", "description": "Complete the \"New Anomaly Citizen ID!\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_18": {"name": "The Last Bite is the Setting Sun", "description": "Complete the \"Let's Go, Delicious Investigators!\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_19": {"name": "Yesterday Rose as Always", "description": "Complete the \"Books Are Like Old Friends\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_2": {"name": "Photo Studio Fright Night", "description": "Complete the \"Good Business, Better Treasure\" series quests.", "category": "quest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "quest_20": {"name": "It's! Snowing!", "description": "Complete the \"A Retrograde Poem for You\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_21": {"name": "A Letter from the Past", "description": "Complete the \"Miles Apart\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_22": {"name": "Heroes Not Selling, Help Us Out", "description": "Complete the \"Heroes Don't Always Arrive in Moments of Glory\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_24": {"name": "Life Is One Long Journey", "description": "Complete the \"Goodbye, Yellow Flag\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_25": {"name": "The Train Keeps Moving", "description": "Complete the A Legendary(?) Band's First Page series quest.", "category": "quest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "quest_29": {"name": "Debts and Dues", "description": "Complete Questline: The Scarlet Letter Analysis.", "category": "quest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "quest_3": {"name": "The Moon and Six Fons", "description": "Complete the \"Pawn Moonlight\" series quests.", "category": "quest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "quest_30": {"name": "Detention Blues", "description": "Complete the \"A Breeze Through the Cage\" questline.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_31": {"name": "Dreams Adrift", "description": "Complete the \"Dreamwalk Corridor\" questline.", "category": "quest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "quest_32": {"name": "Dragon Hunter", "description": "Clear \"999 Nights\" on Normal difficulty.", "category": "quest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}, {"item_id": "Title_FinalTower_001", "amount": 1}]}, + "quest_33": {"name": "Fuzzy Hero", "description": "Clear \"999 Nights\" on Hard difficulty.", "category": "play", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}, {"item_id": "Title_FinalTower_002", "amount": 1}]}, + "quest_34": {"name": "Legend of Warren", "description": "Clear \"999 Nights\" on Nightmare difficulty.", "category": "play", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}, {"item_id": "Title_FinalTower_003", "amount": 1}]}, + "quest_36": {"name": "Adventure Awaits", "description": "Leave the Fuzzy Village.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_37": {"name": "One Fuzzy Move Ahead", "description": "Complete Quest: The Fuzzy's Gambit.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_38": {"name": "Gone Bad", "description": "Complete Quest: Fuzzy Club.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_39": {"name": "Nothing Really Happened", "description": "Complete Quest: All Quiet in the Future.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_4": {"name": "Taygedo the Rizzler", "description": "Complete the \"Love that Begins with Lies\" series quests.", "category": "quest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "quest_40": {"name": "Sword or Guitar?", "description": "Complete Quest: Rex Fuzzendae.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_41": {"name": "Fuzzy's Precious! Grrgl!", "description": "Complete Quest: Gone Fuzzy.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_42": {"name": "Hardworking Fuzzy", "description": "Complete Quest: The Hero Is a Part-Timer.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_43": {"name": "Clear Horizon", "description": "Complete the Fogden Game series quests.\n", "category": "quest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "quest_5": {"name": "Just Like Autumn", "description": "Complete the \"Where Words Sprout, Fall Unfolds\" series quests.", "category": "quest", "quality": "execellent", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 20}]}, + "quest_6": {"name": "300% Sync Rate!", "description": "Complete the \"We! Are! One!\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_7": {"name": "Waste Sorting", "description": "Complete the \"Fluffed Away?!\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_8": {"name": "Guess Who I Am?", "description": "Complete the \"Transform! Huhehaha!\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]}, + "quest_9": {"name": "Small Size, Big Appetite!", "description": "Complete \"The Records Collector\" series quests.", "category": "quest", "quality": "high", "target": 1, "rewards": [{"item_id": "Annulith", "amount": 10}]} +} diff --git a/mappings/arcs.json b/mappings/arcs.json index 1a23a05..a83a4b4 100644 --- a/mappings/arcs.json +++ b/mappings/arcs.json @@ -10,8 +10,10 @@ "fork_Butterfly": {"name": "Reality Refuge", "rank": "S"}, "fork_Castle": {"name": "Call of the Twisted City", "rank": "A"}, "fork_Crowbar": {"name": "Time Bandit", "rank": "A"}, + "fork_DemonBlade": {"name": "Ravenous Blade", "rank": "S"}, "fork_Door": {"name": "The Wrong Gate", "rank": "S"}, "fork_dustbin": {"name": "Dangerous Game", "rank": "B"}, + "fork_GoldRecord": {"name": "Voice of the Voyager", "rank": "S"}, "fork_GoldWool": {"name": "What's Desired", "rank": "S"}, "fork_jiaojuan": {"name": "Shiny Days", "rank": "A"}, "fork_jingmotingyuan": {"name": "Camellia Society", "rank": "S"}, diff --git a/mappings/characters.json b/mappings/characters.json index b037890..539c719 100644 --- a/mappings/characters.json +++ b/mappings/characters.json @@ -9,6 +9,7 @@ "1023": {"name": "Baicang", "rank": "S"}, "1025": {"name": "Hathor", "rank": "S"}, "1033": {"name": "Adler", "rank": "A"}, + "1036": {"name": "Zankou", "rank": "S"}, "1039": {"name": "Fadia", "rank": "S"}, "1046": {"name": "Zero", "rank": "S"}, "1051": {"name": "Zero", "rank": "S"}, @@ -18,7 +19,9 @@ "1056": {"name": "Lacrimosa", "rank": "S"}, "1070": {"name": "Aurelia", "rank": "A"}, "1071": {"name": "Chaos", "rank": "S"}, + "1072": {"name": "Linko", "rank": "S"}, "1073": {"name": "Chiz", "rank": "S"}, "1075": {"name": "Iroi", "rank": "S"}, - "1076": {"name": "Shinku", "rank": "S"} + "1076": {"name": "Shinku", "rank": "S"}, + "1091": {"name": "Lacrimosa", "rank": "S"} } diff --git a/mappings/items.json b/mappings/items.json index 263f9ef..42c8a9f 100644 --- a/mappings/items.json +++ b/mappings/items.json @@ -1,40 +1,51 @@ { + "BussinessCard_MangHe_bsj": {"type": "cosmetic", "name": "Trackside Shadow", "rank": "S"}, + "CharacterUpMaterial_lv3": {"type": "item", "name": "Elite Hunter Guide", "rank": "A"}, + "CityAbility_UpMaterial": {"type": "item", "name": "Dreamless Seed", "rank": "B"}, "Dice_ticket_01": {"type": "item", "name": "Warp Piece", "rank": "S"}, "Dice_ticket_02": {"type": "item", "name": "Lost Piece", "rank": "A"}, "Dicelimite": {"type": "item", "name": "Solid Dice", "rank": "S"}, "DiceNormal": {"type": "item", "name": "Fabricated Dice", "rank": "S"}, + "Fashion_1051_rpg_level3_Base": {"type": "cosmetic", "name": "Glory Days", "rank": "S"}, + "Fashion_1051_rpg_level3_Dye1": {"type": "cosmetic", "name": "Glory Days - Braveheart", "rank": "S"}, + "Fashion_1051_rpg_level3_Dye2": {"type": "cosmetic", "name": "Glory Days - Starfall", "rank": "S"}, + "Fashion_1075_rpg_level2_Base": {"type": "cosmetic", "name": "Archmage", "rank": "S"}, "Fashion_character_1004_01": {"type": "cosmetic", "name": "Gilded Rhapsody", "rank": "S"}, "Fashion_character_1010": {"type": "cosmetic", "name": "Phoenix Kick", "rank": "S"}, + "Fashion_character_1010_swimsuit": {"type": "cosmetic", "name": "Sunny Surf", "rank": "S"}, + "Fashion_character_1019_swimsuit": {"type": "cosmetic", "name": "Saltwater Taffy", "rank": "S"}, + "Fashion_character_1036_03": {"type": "cosmetic", "name": "Autumn Vignette", "rank": "S"}, + "Fashion_character_1051_07": {"type": "cosmetic", "name": "Perfect Partners", "rank": "S"}, + "Fashion_character_1051_swimsuit": {"type": "cosmetic", "name": "Cloudless Summer", "rank": "S"}, "Fashion_character_1052_01": {"type": "cosmetic", "name": "Priceless Orchid", "rank": "S"}, "Fashion_character_1071_01": {"type": "cosmetic", "name": "Clear Skies", "rank": "S"}, - "Fashion_character_1075_01": {"type": "cosmetic", "name": "Summer Dream", "rank": "S"}, + "Fashion_character_1072_01": {"type": "cosmetic", "name": "Surfing Among Stars", "rank": "S"}, "Fashion_character_1076_01": {"type": "cosmetic", "name": "Student of Terrasea", "rank": "S"}, "Fashion_Glide_1004": {"type": "cosmetic", "name": "Tomato Duo", "rank": "A"}, "Fashion_Glide_1010": {"type": "cosmetic", "name": "Underboss-of-the-Underboss", "rank": "A"}, + "Fashion_Glide_1036": {"type": "cosmetic", "name": "Scarlet Sash", "rank": "A"}, "Fashion_Glide_1052": {"type": "cosmetic", "name": "Orchid Breeze", "rank": "A"}, "Fashion_Glide_1071": {"type": "cosmetic", "name": "Skyrider", "rank": "A"}, - "Fashion_glide_1075": {"type": "cosmetic", "name": "Skyrider", "rank": "A"}, + "Fashion_Glide_1072": {"type": "cosmetic", "name": "Sheepcopter", "rank": "A"}, + "Fashion_Glide_1075": {"type": "cosmetic", "name": "Sheepcopter", "rank": "A"}, "Fashion_Glide_1076": {"type": "cosmetic", "name": "Overcast Canopy", "rank": "A"}, "Fashion_vehicle_1004_V021": {"type": "cosmetic", "name": "Tomato Cruise - Livery", "rank": "S"}, "Fashion_vehicle_1010_V008": {"type": "cosmetic", "name": "Tiger Incoming! - Livery", "rank": "S"}, + "Fashion_vehicle_1036_V043": {"type": "cosmetic", "name": "Sandy Mirage - Livery", "rank": "S"}, "Fashion_vehicle_1052_V024": {"type": "cosmetic", "name": "Autumn Haze - Livery", "rank": "S"}, "Fashion_vehicle_1071_V010": {"type": "cosmetic", "name": "Hound Blitz - Livery", "rank": "S"}, - "Fashion_vehicle_1075_V006": {"type": "cosmetic", "name": "Hidden Dragon - Livery", "rank": "S"}, + "Fashion_vehicle_1075_V006": {"type": "cosmetic", "name": "Three's Company - Livery", "rank": "S"}, "Fashion_vehicle_1076_V024": {"type": "cosmetic", "name": "Hidden Dragon - Livery", "rank": "S"}, "Fashion_vehicleSkin_1052_V016": {"type": "cosmetic", "name": "Golden Breeze - Livery", "rank": "S"}, - "vehicle039": {"type": "item", "name": "Draco", "rank": "S"}, - "Fashion_1051_rpg_level3_Dye1": {"type": "cosmetic", "name": "Glory Days - Braveheart", "rank": "S"}, - "Fashion_1051_rpg_level3_Dye2": {"type": "cosmetic", "name": "Glory Days - Starfall", "rank": "S"}, - "Fashion_1051_rpg_level3_Base": {"type": "cosmetic", "name": "Glory Days", "rank": "S"}, - "SpecialGift_ticket": {"type": "item", "name": "Floe Cinema Ticket", "rank": "A"}, "Fons": {"type": "item", "name": "Fons", "rank": "A"}, - "CharacterUpMaterial_lv3": {"type": "item", "name": "Elite Hunter Guide", "rank": "A"}, - "Gold": {"type": "item", "name": "Beetle Coin", "rank": "B"}, - "CityAbility_UpMaterial": {"type": "item", "name": "Dreamless Seed", "rank": "B"}, - "SpecialGift_letter": {"type": "item", "name": "A Handwritten Letter", "rank": "S"}, - "Frame_MangHe_shinku": {"type": "cosmetic", "name": "Ember Trail", "rank": "S"}, - "Fashion_character_1051_07": {"type": "cosmetic", "name": "Perfect Partners", "rank": "S"}, - "vehicle026": {"type": "item", "name": "Porsche 918 Spyder", "rank": "S"}, "Frame_MangHe_bsj": {"type": "cosmetic", "name": "Heartbeat Moment", "rank": "S"}, - "BussinessCard_MangHe_bsj": {"type": "cosmetic", "name": "Trackside Shadow", "rank": "S"} + "Frame_MangHe_shinku": {"type": "cosmetic", "name": "Ember Trail", "rank": "S"}, + "Frame_MangHe_summer_01": {"type": "cosmetic", "name": "Endless Summer", "rank": "S"}, + "Gold": {"type": "item", "name": "Beetle Coin", "rank": "B"}, + "SpecialGift_letter": {"type": "item", "name": "A Handwritten Letter", "rank": "S"}, + "SpecialGift_ticket": {"type": "item", "name": "Floe Cinema Ticket", "rank": "A"}, + "vehicle026": {"type": "item", "name": "Porsche 918 Spyder", "rank": "S"}, + "vehicle039": {"type": "item", "name": "Draco", "rank": "S"}, + "Vehicle044": {"type": "item", "name": "Tide", "rank": "S"}, + "weeklycloneboss_selectbox_02": {"type": "item", "name": "Anomaly Pilgrimage Material Selection Box", "rank": "S"} } diff --git a/pyproject.toml b/pyproject.toml index c5c4a4d..47b3bb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "nte-history-exporter" -version = "0.2.1" -description = "Cross-platform Neverness to Everness pull-history exporter." +version = "0.3.0" +description = "Cross-platform Neverness to Everness pull-history and achievement exporter." requires-python = ">=3.10" dependencies = [] diff --git a/src/nte_history_exporter/__init__.py b/src/nte_history_exporter/__init__.py index 3ced358..493f741 100644 --- a/src/nte_history_exporter/__init__.py +++ b/src/nte_history_exporter/__init__.py @@ -1 +1 @@ -__version__ = "0.2.1" +__version__ = "0.3.0" diff --git a/src/nte_history_exporter/cli.py b/src/nte_history_exporter/cli.py index a02ed86..5fa1fef 100644 --- a/src/nte_history_exporter/cli.py +++ b/src/nte_history_exporter/cli.py @@ -20,7 +20,7 @@ def build_parser() -> argparse.ArgumentParser: description="Decode NTE pull history from mitmproxy .flows captures or live capture." ) parser.add_argument("capture_source", nargs="?", help="mitmproxy .flows file; omit when using --live") - parser.add_argument("--live", action="store_true", help="capture live UDP traffic instead of reading a .flows file") + parser.add_argument("--live", action="store_true", help="capture live game traffic instead of reading a .flows file") parser.add_argument("--flow-index", type=int, default=None) parser.add_argument("--interface-ip", default=None, help="local IPv4 address to bind for live capture") parser.add_argument( diff --git a/src/nte_history_exporter/console.py b/src/nte_history_exporter/console.py index 10bb086..0fc8950 100644 --- a/src/nte_history_exporter/console.py +++ b/src/nte_history_exporter/console.py @@ -62,7 +62,7 @@ def print_banner() -> None: print() print(rule("=")) print(style(f" NTE History Exporter v{EXPORTER_VERSION}", BOLD, CYAN)) - print(style(f" {GAME_NAME} pull history -> tracker JSON", DIM)) + print(style(f" {GAME_NAME} history and achievements -> JSON", DIM)) print(style(f" {REPOSITORY_URL}", DIM)) heart = "❤️" if (getattr(sys.stdout, "encoding", None) or "").lower().replace("-", "") == "utf8" else "<3" print(style(" Created with ", DIM) + style(heart, RED) + style(" by Golumpa", DIM)) @@ -75,23 +75,29 @@ def print_live_instructions(local_ip: str, backend: str = "windows_raw", detail: backend_detail = f" ({detail})" if detail and detail != local_ip else "" print(style(f" Capture backend: {backend}{backend_detail}", DIM)) print() - print(style(" How to export your pull history", BOLD)) - print(" 1. For automatic user UID detection, start this tool") - print(" before pressing Start on the game's main menu.") - print(" Already in game? You can still capture history;") - print(" the tool will ask for your UID if it cannot detect it.") - print(" 2. Open a supported history screen:") - print(style(" Monopoly > Standard Board history", CYAN)) - print(style(" Monopoly > Limited Character Board history", CYAN)) - print(style(" Gashapon > Arc Miracle Box history", CYAN)) - print(style(" Gashapon > Mystery Box history", CYAN)) - print(" 3. Start at page 1 and scroll down through every page") - print(" you want exported.") - print(" 4. Scroll one page past where you plan to stop so the") - print(" last pull group can be confirmed as complete.") - print(" 5. You can open several boards in the same session.") + print(style(" Before you begin", BOLD)) + print(" Start this exporter before pressing Start on the game's") + print(" main menu. This lets it capture your achievements and") + print(" detect your account UID and server automatically.") print() - print(style(" Waiting for history pages... press any key here when done.", BOLD, GREEN)) + print(" Already in game? Pull history still works, but achievement") + print(" capture requires restarting the game. Missing account details") + print(" will be requested when your exports are saved.") + print() + print(style(" Pull history", BOLD)) + print(" Open a supported history screen, start at page 1, then") + print(" scroll through every page you want to export. Go one page") + print(" further so the exporter can confirm the final pull group.") + print() + print(style(" Monopoly:", DIM) + " Standard Board or Limited Character Board") + print(style(" Gashapon:", DIM) + " Arc Miracle Box or Mystery Box") + print(style(" You can capture more than one board in the same session.", DIM)) + print() + print(style(" Achievements", BOLD)) + print(" Your tracked achievements are captured automatically when") + print(" you log in after following the step above.") + print() + print(style(" Ready - use the game, then press any key here when finished.", BOLD, GREEN)) print(rule()) @@ -100,6 +106,29 @@ def print_page_captured(label: str, page: int | None, *, recaptured: bool = Fals print(style(" + ", GREEN, BOLD) + label + style(f" {action} {page}", DIM)) +def print_achievements_captured( + in_game_completed: int, + in_game_in_progress: int, + playstation_completed: int, + playstation_in_progress: int, +) -> None: + print(style(" + ", GREEN, BOLD) + "Achievements captured") + print( + style( + f" In-game {in_game_completed} completed, " + f"{in_game_in_progress} in progress", + DIM, + ) + ) + print( + style( + f" PlayStation {playstation_completed} completed, " + f"{playstation_in_progress} in progress", + DIM, + ) + ) + + def print_missing_pages(label: str, pages: list[int]) -> None: page_list = ", ".join(str(page) for page in pages) print(style(" ! ", YELLOW, BOLD) + style(f"{label} missing page(s): {page_list}.", YELLOW, BOLD)) @@ -169,7 +198,7 @@ def print_problem(text: str) -> None: def print_update_available(current_version: str, latest_version: str, release_url: str) -> None: print() print(style(f" Update available: v{current_version} -> {latest_version}", YELLOW, BOLD)) - print(style(" Some banner/reward mappings may be incomplete in this version.", YELLOW)) + print(style(" Some reward or achievement mappings may be incomplete in this version.", YELLOW)) print(style(" Updating is recommended for the most accurate export labels.", YELLOW)) print(style(f" Download when ready: {release_url}", YELLOW)) diff --git a/src/nte_history_exporter/constants.py b/src/nte_history_exporter/constants.py index 883f920..7f7d059 100644 --- a/src/nte_history_exporter/constants.py +++ b/src/nte_history_exporter/constants.py @@ -13,7 +13,7 @@ ARC_BANNER_NAME = "Arc Miracle Box" MYSTERY_BOX_BANNER_ID = "Gashapon_MysteryBox" MYSTERY_BOX_BANNER_NAME = "Mystery Box" EXPORTER_NAME = "nte-history-exporter" -EXPORTER_VERSION = "0.2.1" +EXPORTER_VERSION = "0.3.0" HISTORY_REQUEST_BANNER = 4220 HISTORY_REQUEST_LENGTH = 45 diff --git a/src/nte_history_exporter/decoder/achievement.py b/src/nte_history_exporter/decoder/achievement.py new file mode 100644 index 0000000..0c3b33e --- /dev/null +++ b/src/nte_history_exporter/decoder/achievement.py @@ -0,0 +1,169 @@ +import re +import struct +from dataclasses import dataclass +from datetime import datetime, timezone + +ACHIEVEMENT_RECORD_MARKER = b"AchievementRecord\0" +ACHIEVEMENT_ID = re.compile(rb"([A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+)\0") +RECORD_COUNT_OFFSET = 22 +RECORDS_OFFSET = 38 +COMPLETION_TICKS_OFFSET = 8 +MAX_ACHIEVEMENTS = 1_000 +DOTNET_EPOCH_TICKS = 621_355_968_000_000_000 +DOTNET_TICKS_PER_SECOND = 10_000_000 + + +@dataclass(frozen=True) +class AchievementRecord: + achievement_id: str + progress: int + completion_ticks: int + + @property + def completed(self) -> bool: + return self.completion_ticks != 0 + + @property + def status(self) -> str: + if self.completed: + return "completed" + # Presence in the server response means the achievement has tracked + # state. Compound achievements can have hidden checklist progress while + # exposing a numeric progress value of zero to the client. + return "in_progress" + + @property + def completed_at(self) -> str | None: + if not self.completed: + return None + unix_seconds = ( + self.completion_ticks - DOTNET_EPOCH_TICKS + ) / DOTNET_TICKS_PER_SECOND + try: + return datetime.fromtimestamp(unix_seconds, timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S" + ) + except (OverflowError, OSError, ValueError): + return None + + +def extract_achievement_ids(stream: bytes) -> list[str]: + return [ + record.achievement_id + for record in extract_achievement_records(stream) + if record.completed + ] + + +def extract_achievement_records(stream: bytes) -> list[AchievementRecord]: + for block in _game_data_blocks(stream): + decoded = _decompress_lz4_block(block) + marker = decoded.find(ACHIEVEMENT_RECORD_MARKER) + if marker < 0: + continue + records = _achievement_records(decoded, marker) + if records: + return records + return [] + + +def reassemble_tcp_segments(segments: list[tuple[int, bytes]]) -> bytes: + if not segments: + return b"" + output = bytearray() + start = min(sequence for sequence, _payload in segments) + for sequence, payload in sorted(set(segments)): + offset = sequence - start + if offset > len(output): + return b"" + output.extend(payload[max(0, len(output) - offset) :]) + return bytes(output) + + +def _achievement_records(decoded: bytes, marker: int) -> list[AchievementRecord]: + # The component header stores its record count before a fixed schema descriptor. + if marker + RECORDS_OFFSET > len(decoded): + return [] + count = struct.unpack_from(" MAX_ACHIEVEMENTS: + return [] + matches = list(ACHIEVEMENT_ID.finditer(decoded, marker + RECORDS_OFFSET)) + if len(matches) < count: + return [] + records = [] + for match in matches[:count]: + # Each name is followed by an unsigned progress value and completion ticks. + progress_offset = match.end() + ticks_offset = match.end() + COMPLETION_TICKS_OFFSET + if ticks_offset + 8 > len(decoded): + return [] + records.append( + AchievementRecord( + achievement_id=match.group(1).decode("ascii"), + progress=struct.unpack_from(" len(stream): + return + frame = stream[position + 4 : frame_end] + if len(frame) < 4: + return + header_size = struct.unpack_from(" bytes: + output = bytearray() + position = 0 + while position < len(data): + if len(data) - position <= 3 and not any(data[position:]): + break + token = data[position] + position += 1 + literal_length, position = _lz4_length(data, position, token >> 4) + literal_end = position + literal_length + if literal_end > len(data): + return b"" + output.extend(data[position:literal_end]) + position = literal_end + if position == len(data) or ( + len(data) - position <= 3 and not any(data[position:]) + ): + break + if position + 2 > len(data): + return b"" + offset = struct.unpack_from(" len(output): + return b"" + match_length, position = _lz4_length(data, position, token & 15) + for _ in range(match_length + 4): + output.append(output[-offset]) + return bytes(output) + + +def _lz4_length(data: bytes, position: int, length: int) -> tuple[int, int]: + if length != 15: + return length, position + while position < len(data): + value = data[position] + position += 1 + length += value + if value != 255: + return length, position + return length, position diff --git a/src/nte_history_exporter/decoder/arc.py b/src/nte_history_exporter/decoder/arc.py index bc5ed10..48d498e 100644 --- a/src/nte_history_exporter/decoder/arc.py +++ b/src/nte_history_exporter/decoder/arc.py @@ -21,7 +21,7 @@ from nte_history_exporter.constants import ( ) from nte_history_exporter.decoder.boundary import select_continuous_run_from_page_1 from nte_history_exporter.decoder.run import fmt_packet_time -from nte_history_exporter.mappings import ARC_META +from nte_history_exporter.mappings import ARC_META_BY_CASEFOLD from nte_history_exporter.decoder.structured_protocol import ( StructuredProtocolAssembler, StructuredRecord, @@ -95,7 +95,7 @@ def _parse_legacy_arc_response(response: bytes) -> list[dict[str, Any]]: timestamp_raw = response[pos : pos + 8] pos += 8 arc_id = decode_arc_key(name_raw) or name_raw.hex() - meta = ARC_META.get(arc_id, {}) + canonical_id, meta = _resolve_arc_metadata(arc_id) try: ticks, unix_seconds, timestamp_decoded = decode_arc_timestamp(timestamp_raw) except ValueError: @@ -107,7 +107,7 @@ def _parse_legacy_arc_response(response: bytes) -> list[dict[str, Any]]: "record_len": pos - start, "reward_key_hex": name_raw.hex(), "reward_type": "arc", - "reward_id": arc_id, + "reward_id": canonical_id, "reward_name": meta.get("name", "UNKNOWN"), "reward_rank": meta.get("rank", ""), "type_key_hex": type_raw.hex(), @@ -122,18 +122,15 @@ def _parse_legacy_arc_response(response: bytes) -> list[dict[str, Any]]: return records -def _arc_metadata(arc_id: str) -> dict[str, Any]: - direct = ARC_META.get(arc_id) - if direct is not None: - return direct - folded = arc_id.casefold() - return next((meta for item_id, meta in ARC_META.items() if item_id.casefold() == folded), {}) +def _resolve_arc_metadata(arc_id: str) -> tuple[str, dict[str, Any]]: + canonical_id, meta = ARC_META_BY_CASEFOLD.get(arc_id.casefold(), (arc_id, {})) + return canonical_id, meta def structured_arc_rows(structured_rows: list[StructuredRecord]) -> list[dict[str, Any]]: records = [] for structured in structured_rows: - meta = _arc_metadata(structured.item_id) + canonical_id, meta = _resolve_arc_metadata(structured.item_id) records.append( { "record_start": structured.record_start, @@ -141,7 +138,7 @@ def structured_arc_rows(structured_rows: list[StructuredRecord]) -> list[dict[st "record_len": structured.record_end - structured.record_start, "reward_key_hex": "", "reward_type": "arc", - "reward_id": structured.item_id, + "reward_id": canonical_id, "reward_name": meta.get("name", "UNKNOWN"), "reward_rank": meta.get("rank", ""), "type_key_hex": "", diff --git a/src/nte_history_exporter/decoder/mystery_box.py b/src/nte_history_exporter/decoder/mystery_box.py index 2c9eaf2..09088af 100644 --- a/src/nte_history_exporter/decoder/mystery_box.py +++ b/src/nte_history_exporter/decoder/mystery_box.py @@ -24,16 +24,11 @@ from nte_history_exporter.constants import ( from nte_history_exporter.decoder.boundary import select_continuous_run_from_page_1 from nte_history_exporter.decoder.protocol import infer_reward_type from nte_history_exporter.decoder.run import fmt_packet_time -from nte_history_exporter.mappings import REWARDS_BY_ID +from nte_history_exporter.mappings import REWARDS_BY_CASEFOLD DOTNET_TICKS_PER_SECOND = 10_000_000 MAX_RECORDS_PER_BLOCK = 100 MAX_REWARD_ID_LENGTH = 256 -REWARDS_BY_CASEFOLDED_ID = { - reward_id.casefold(): reward for reward_id, reward in REWARDS_BY_ID.items() -} - - def is_mystery_box_history_request(content: bytes) -> bool: if len(content) < MYSTERY_BOX_HISTORY_REQUEST_LENGTH: return False @@ -108,16 +103,15 @@ def _parse_view(data: bytes) -> list[dict[str, Any]]: timestamp_raw = data[pos : pos + 8] pos += 8 ticks, unix_seconds, timestamp_decoded = _decode_timestamp(timestamp_raw) - reward = REWARDS_BY_ID.get(reward_id) or REWARDS_BY_CASEFOLDED_ID.get( - reward_id.casefold(), {} - ) + reward = REWARDS_BY_CASEFOLD.get(reward_id.casefold(), {}) + canonical_reward_id = reward.get("id", reward_id) rows.append( { "record_start": record_start, "record_end": pos, "record_len": pos - record_start, "reward_type": reward.get("type") or infer_reward_type(reward_id), - "reward_id": reward_id, + "reward_id": canonical_reward_id, "reward_name": reward.get("name", ""), "reward_rank": reward.get("rank"), "quantity": quantity, diff --git a/src/nte_history_exporter/decoder/protocol.py b/src/nte_history_exporter/decoder/protocol.py index 3fed6c7..17c7267 100644 --- a/src/nte_history_exporter/decoder/protocol.py +++ b/src/nte_history_exporter/decoder/protocol.py @@ -15,7 +15,7 @@ from nte_history_exporter.constants import ( TIMESTAMP_TICKS_PER_SECOND, VALID_DICE_FIELDS, ) -from nte_history_exporter.mappings import REWARDS_BY_ID +from nte_history_exporter.mappings import REWARDS_BY_CASEFOLD from nte_history_exporter.decoder.structured_protocol import ( StructuredRecord, parse_structured_records, @@ -61,11 +61,12 @@ def decode_reward_key(raw: bytes) -> str: def infer_reward_type(reward_id: str) -> str: if not reward_id: return "" - if reward_id.startswith("fork_"): + folded = reward_id.casefold() + if folded.startswith("fork_"): return "arc" if reward_id.isdigit(): return "character" - if reward_id.startswith("Fashion_"): + if folded.startswith("fashion_"): return "cosmetic" return "item" @@ -165,7 +166,7 @@ def classify_result_type( ) -> tuple[str, int | None]: if dice is None or dice_offset is None: return "unknown", None - if reward_id == "Dice_ticket_01" and WARP_PIECE_CHASE_PATTERN in chunk_without_marker: + if reward_id.casefold() == "dice_ticket_01" and WARP_PIECE_CHASE_PATTERN in chunk_without_marker: return "chase_reward", -4 if dice == 0: return "points_gift", 0 @@ -182,13 +183,14 @@ def classify_result_type( def guess_quantity(chunk_hex: str, reward_id: str, result_type: str | None = None) -> int | None: - if reward_id == "Dice_ticket_01": + folded = reward_id.casefold() + if folded == "dice_ticket_01": if result_type == "chase_reward": return 30 return 4 - if reward_id == "DiceNormal": + if folded == "dicenormal": return 1 - if reward_id == "Dice_ticket_02": + if folded == "dice_ticket_02": if "c8b0d4c0" in chunk_hex: return 50 if "c8b0ccc0" in chunk_hex: @@ -266,7 +268,8 @@ def _decode_aligned_response_records(response_content: bytes) -> list[dict[str, elif result_type == "chase_reward": dice = -4 dice_raw = -4 - reward = REWARDS_BY_ID.get(reward_id, {}) + reward = _reward_metadata(reward_id) + canonical_reward_id = reward.get("id", reward_id) timestamp_raw = response_content[marker_offset + len(marker) : marker_offset + len(marker) + 8] timestamp_ticks, timestamp_unix, timestamp_decoded = decode_history_timestamp(timestamp_raw) chunk_hex = chunk.hex() @@ -288,7 +291,7 @@ def _decode_aligned_response_records(response_content: bytes) -> list[dict[str, "dice_offset_in_record": dice_offset, "reward_key_hex": key_hex, "reward_type": reward.get("type") or infer_reward_type(reward_id), - "reward_id": reward_id, + "reward_id": canonical_reward_id, "reward_name": reward.get("name", ""), "reward_rank": reward.get("rank"), "quantity": guess_quantity(chunk_hex, reward_id, result_type), @@ -335,8 +338,8 @@ def _enrich_heuristic_rows( return heuristic_rows for heuristic, structured in zip(heuristic_rows, structured_rows): if not heuristic.get("reward_id"): - heuristic["reward_id"] = structured.item_id reward = _reward_metadata(structured.item_id) + heuristic["reward_id"] = reward.get("id", structured.item_id) heuristic["reward_type"] = reward.get("type") or infer_reward_type(structured.item_id) heuristic["reward_name"] = reward.get("name", "") heuristic["reward_rank"] = reward.get("rank") @@ -361,11 +364,7 @@ def _enrich_heuristic_rows( def _reward_metadata(reward_id: str) -> dict[str, Any]: - direct = REWARDS_BY_ID.get(reward_id) - if direct is not None: - return direct - folded = reward_id.casefold() - return next((meta for item_id, meta in REWARDS_BY_ID.items() if item_id.casefold() == folded), {}) + return REWARDS_BY_CASEFOLD.get(reward_id.casefold(), {}) def structured_monopoly_rows(structured_rows: list[StructuredRecord]) -> list[dict[str, Any]]: @@ -373,6 +372,7 @@ def structured_monopoly_rows(structured_rows: list[StructuredRecord]) -> list[di for row_index, structured in enumerate(structured_rows, start=1): dice, dice_raw, result_type, result_source = _structured_result(structured.roll_points_raw) reward = _reward_metadata(structured.item_id) + canonical_reward_id = reward.get("id", structured.item_id) rows.append( { "row": row_index, @@ -391,7 +391,7 @@ def structured_monopoly_rows(structured_rows: list[StructuredRecord]) -> list[di "dice_offset_in_record": None, "reward_key_hex": "", "reward_type": reward.get("type") or infer_reward_type(structured.item_id), - "reward_id": structured.item_id, + "reward_id": canonical_reward_id, "reward_name": reward.get("name", ""), "reward_rank": reward.get("rank"), "quantity": structured.count, diff --git a/src/nte_history_exporter/decoder/structured_protocol.py b/src/nte_history_exporter/decoder/structured_protocol.py index e93f79e..a94a617 100644 --- a/src/nte_history_exporter/decoder/structured_protocol.py +++ b/src/nte_history_exporter/decoder/structured_protocol.py @@ -12,6 +12,9 @@ MONOPOLY_MARKER = b"FMonopolyLotteryRecordData" FORK_MARKER = b"FForkLotteryRecordData" MAX_ROWS_PER_BLOCK = 100 MAX_STRING_LENGTH = 256 +# Bit-packed UDP views can omit up to the final three protocol-padding bytes. +# The declared size covers that padding, while all declared rows remain intact. +MAX_SHIFTED_BLOCK_PADDING_SHORTFALL = 3 DOTNET_EPOCH_TICKS = 621_355_968_000_000_000 DOTNET_TICKS_PER_SECOND = 10_000_000 MIN_UNIX_SECONDS = 1_500_000_000 @@ -230,7 +233,11 @@ def _parse_block( pos += 12 if row_count > MAX_ROWS_PER_BLOCK: raise StructuredProtocolError(f"row count is too large: {row_count}") - if declared_size > len(data) - pos: + size_shortfall = declared_size - (len(data) - pos) + if size_shortfall > 0 and not ( + view_name.startswith("shift8:") + and size_shortfall <= MAX_SHIFTED_BLOCK_PADDING_SHORTFALL + ): raise StructuredProtocolError("declared block size exceeds payload") reader = _Reader(data, pos) diff --git a/src/nte_history_exporter/export/json_export.py b/src/nte_history_exporter/export/json_export.py index 774eef9..4c500de 100644 --- a/src/nte_history_exporter/export/json_export.py +++ b/src/nte_history_exporter/export/json_export.py @@ -12,6 +12,8 @@ from nte_history_exporter.constants import ( MYSTERY_BOX_BANNER_ID, ) from nte_history_exporter.decoder.server_region import account_region_for_server +from nte_history_exporter.decoder.achievement import AchievementRecord +from nte_history_exporter.mappings import ACHIEVEMENTS_BY_CASEFOLD def build_export_json( @@ -78,6 +80,83 @@ def build_export_json( return export +def build_achievement_export_json( + achievement_records: list[AchievementRecord], + *, + source: str = "packet_capture", + capture_source: str | None = None, + user_uid: str | None = None, + server_id: str | None = None, +) -> dict[str, Any]: + categories: dict[str, list[dict[str, Any]]] = {} + scan = { + "in_game": { + "total_achievements": 0, + "completed_achievements": 0, + "in_progress_achievements": 0, + }, + "playstation": { + "total_achievements": 0, + "completed_achievements": 0, + "in_progress_achievements": 0, + }, + } + for achievement in achievement_records: + achievement_id = achievement.achievement_id + is_playstation = achievement_id.casefold().startswith("playstation_") + summary = scan["playstation" if is_playstation else "in_game"] + summary["total_achievements"] += 1 + if achievement.completed: + summary["completed_achievements"] += 1 + else: + summary["in_progress_achievements"] += 1 + category, separator, _number = achievement_id.partition("_") + category_key = category.casefold() if separator else "uncategorized" + record = { + "id": achievement_id, + "platform": "playstation" if is_playstation else "in_game", + "status": achievement.status, + "progress": achievement.progress, + "completed": achievement.completed, + "completed_at": achievement.completed_at, + } + metadata = ACHIEVEMENTS_BY_CASEFOLD.get(achievement_id.casefold()) + if metadata: + record.update( + { + "name": metadata["name"], + "description": metadata["description"], + "quality": metadata["quality"], + "target": metadata["target"], + "rewards": metadata["rewards"], + } + ) + categories.setdefault(category_key, []).append(record) + + export: dict[str, Any] = { + "format": "nte-achievement-export", + "format_version": 1, + "game": GAME_NAME, + "source": source, + } + if capture_source: + export["capture_source"] = capture_source + export["exporter"] = {"name": EXPORTER_NAME, "version": __version__} + export["scan"] = scan + + normalized_user_uid = user_uid.strip() if user_uid else "" + normalized_server_id = str(server_id).strip() if server_id else "" + if normalized_user_uid: + export["user_uid"] = normalized_user_uid + if normalized_server_id: + export["server_id"] = normalized_server_id + account_region = account_region_for_server(normalized_server_id) + if account_region: + export["account_region"] = account_region + export["categories"] = categories + return export + + def _record_for_export(row: dict[str, Any]) -> dict[str, Any]: if row.get("pool_group_id") == ARC_BANNER_ID: return { diff --git a/src/nte_history_exporter/live_capture/runner.py b/src/nte_history_exporter/live_capture/runner.py index 06e0914..f36b6f8 100644 --- a/src/nte_history_exporter/live_capture/runner.py +++ b/src/nte_history_exporter/live_capture/runner.py @@ -9,9 +9,16 @@ from pathlib import Path from nte_history_exporter import console from nte_history_exporter.constants import POOL_META +from nte_history_exporter.decoder.achievement import ( + extract_achievement_records, + reassemble_tcp_segments, +) from nte_history_exporter.decoder.boundary import annotate_groups, select_continuous_run_from_page_1 from nte_history_exporter.export.csv_export import write_csv -from nte_history_exporter.export.json_export import build_export_json +from nte_history_exporter.export.json_export import ( + build_achievement_export_json, + build_export_json, +) from nte_history_exporter.live_capture.backends import open_capture_backend from nte_history_exporter.live_capture.diagnostics import new_diagnostics_path, write_capture_diagnostics from nte_history_exporter.live_capture.session import LiveHistorySession, UdpPacket @@ -79,6 +86,8 @@ def run_live_capture( console.print_capture_fallback(capture.fallback_reason) reported_missing_pages: dict[str, tuple[int, ...]] = {} active_gap_notices: set[str] = set() + tcp_segments: dict[tuple[str, int, str, int], list[tuple[int, bytes]]] = {} + achievement_records = [] try: with StopKeyMonitor() as stop_key: @@ -87,6 +96,44 @@ def run_live_capture( break if packet is None: continue + if ( + not achievement_records + and packet.protocol == "tcp" + and packet.payload + and packet.dst_ip == local_ip + ): + flow = (packet.src_ip, packet.src_port, packet.dst_ip, packet.dst_port) + tcp_segments.setdefault(flow, []).append( + (packet.sequence_number, packet.payload) + ) + if not achievement_records: + achievement_records = extract_achievement_records( + reassemble_tcp_segments(tcp_segments[flow]) + ) + if achievement_records: + in_game = [ + record + for record in achievement_records + if not record.achievement_id.casefold().startswith( + "playstation_" + ) + ] + playstation = [ + record + for record in achievement_records + if record.achievement_id.casefold().startswith( + "playstation_" + ) + ] + console.print_achievements_captured( + sum(record.completed for record in in_game), + sum(record.status == "in_progress" for record in in_game), + sum(record.completed for record in playstation), + sum( + record.status == "in_progress" + for record in playstation + ), + ) pair_count_before = len(session.pairs) matched = session.process_packet( UdpPacket( @@ -129,21 +176,28 @@ def run_live_capture( stats = capture.stats() capture.close() - if stats: - console.print_capture_stats( - stats.received, - stats.dropped, - stats.interface_dropped, - ) - exports = [] + achievement_path = None diagnostics_path = None if write_debug_csv: diagnostics_path = new_diagnostics_path() write_capture_diagnostics(diagnostics_path, session.diagnostic_report()) resolved_user_uid = user_uid or session.user_uid - if session.kinds_seen() and not resolved_user_uid: + if (session.kinds_seen() or achievement_records) and not resolved_user_uid: resolved_user_uid = console.prompt_user_uid() + if achievement_records: + achievement_path = _achievement_path(resolved_user_uid) + achievement_export = build_achievement_export_json( + achievement_records, + source="live_capture", + capture_source=CAPTURE_SOURCE_LABELS.get(capture.name, capture.name), + user_uid=resolved_user_uid, + server_id=session.server_id, + ) + achievement_path.write_text( + json.dumps(achievement_export, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) resolved_server_id = session.server_id if session.kinds_seen() and not resolved_server_id: resolved_server_id = console.prompt_server_id() @@ -182,14 +236,32 @@ def run_live_capture( ) console.print_results_header() + if stats: + console.print_capture_stats( + stats.received, + stats.dropped, + stats.interface_dropped, + ) + if stats: + print() + if not exports: - console.print_problem("No history pages were captured.") - console.print_note("Make sure the capture backend is running, then reopen the") - console.print_note("history screen and scroll from page 1. If no page messages") - console.print_note("appear, return to the main menu and re-enter the game.") + if achievement_path is not None: + console.print_success("Achievement export complete.") + console.print_note("No pull history was captured (this is fine if you only wanted achievements).") + print() + console.print_note(f"Export written: {achievement_path}") + else: + console.print_problem("No history pages were captured.") + console.print_note("Reopen a supported history screen and scroll from page 1.") + console.print_note("If no page messages appear, return to the main menu and re-enter the game.") if diagnostics_path is not None: console.print_note(f"Diagnostics written: {diagnostics_path}") - return {"exports": [], "diagnostics_path": diagnostics_path} + return { + "exports": [], + "diagnostics_path": diagnostics_path, + "achievement_path": achievement_path, + } for item in exports: scan = item["export"]["scan"] @@ -207,6 +279,9 @@ def run_live_capture( if item["csv_path"] is not None: console.print_note(f"CSV written: {item['csv_path']}") console.print_note(f"Export written: {item['json_path']}") + if achievement_path is not None: + print() + console.print_note(f"Export written: {achievement_path}") if diagnostics_path is not None: console.print_note(f"Diagnostics written: {diagnostics_path}") @@ -218,7 +293,24 @@ def run_live_capture( elif copy_clipboard and len(exports) > 1: console.print_note("Multiple banners captured; clipboard copy skipped so one export") console.print_note("does not overwrite another.") - return {"exports": exports, "diagnostics_path": diagnostics_path} + return { + "exports": exports, + "diagnostics_path": diagnostics_path, + "achievement_path": achievement_path, + } + + +def _achievement_path(user_uid: str | None = None) -> Path: + export_dir = Path("exports") + export_dir.mkdir(parents=True, exist_ok=True) + uid_prefix = _safe_filename_part(user_uid) if user_uid else "unknown" + base = export_dir / f"{uid_prefix}_Achievements_{datetime.now():%Y%m%d_%H%M%S}" + path = base.with_suffix(".json") + counter = 2 + while path.exists(): + path = export_dir / f"{base.name}_{counter}.json" + counter += 1 + return path def export_paths(kind: str, user_uid: str | None = None) -> tuple[Path, Path]: diff --git a/src/nte_history_exporter/live_capture/windows_raw.py b/src/nte_history_exporter/live_capture/windows_raw.py index 83b366b..d2035d0 100644 --- a/src/nte_history_exporter/live_capture/windows_raw.py +++ b/src/nte_history_exporter/live_capture/windows_raw.py @@ -17,6 +17,7 @@ class ParsedIpPacket: dst_port: int payload: bytes protocol: str = "udp" + sequence_number: int = 0 ParsedIpUdpPacket = ParsedIpPacket @@ -69,12 +70,14 @@ def parse_ipv4_packet(data: bytes) -> ParsedIpPacket | None: if protocol == 6: if len(data) < ihl + 20: return None - src_port, dst_port = struct.unpack_from("!HH", data, ihl) + src_port, dst_port, sequence_number = struct.unpack_from("!HHI", data, ihl) tcp_header_len = (data[ihl + 12] >> 4) * 4 if tcp_header_len < 20 or len(data) < ihl + tcp_header_len: return None payload = data[ihl + tcp_header_len :] - return ParsedIpPacket(src_ip, dst_ip, src_port, dst_port, payload, "tcp") + return ParsedIpPacket( + src_ip, dst_ip, src_port, dst_port, payload, "tcp", sequence_number + ) return None diff --git a/src/nte_history_exporter/mapping_update.py b/src/nte_history_exporter/mapping_update.py index 8ff2986..2fa99ee 100644 --- a/src/nte_history_exporter/mapping_update.py +++ b/src/nte_history_exporter/mapping_update.py @@ -20,10 +20,11 @@ ASSET_TABLES = { "appearances": "DataTable/Character/Appearance/DT_AppearanceData.json", "illustrations": "DataTable/Gacha/GachaIllustrate.json", "mystery_box_pools": "DataTable/GashaponLottery/DT_GashaponLotteryGlobal.json", + "achievements": "DataTable/DT_AchievementConfigInfo.json", "vehicle_inventory": "DataTable/Vehicle/DT_VehicleItemData.json", "localization": "Localization/en/game.json", } -REWARD_MAPPING_FILES = ("arcs.json", "characters.json", "items.json") +MAPPING_FILES = ("arcs.json", "characters.json", "items.json", "achievements.json") RANK_BY_QUALITY = { "EItemQuality::ITEM_QUALITY_ORANGE": "S", "EItemQuality::ITEM_QUALITY_PURPLE": "A", @@ -103,7 +104,7 @@ def load_assets(*, assets_root: Path | None = None, source_ref: str = DEFAULT_SO def load_current_mappings(directory: Path) -> dict[str, dict[str, dict[str, Any]]]: mappings = {} - for filename in REWARD_MAPPING_FILES: + for filename in MAPPING_FILES: path = directory / filename try: value = json.loads(path.read_text(encoding="utf-8")) @@ -126,13 +127,16 @@ def build_mapping_update( "arcs.json": _build_primary_mapping(assets.tables["arcs"], "arc", translations), "characters.json": _build_primary_mapping(assets.tables["characters"], "character", translations), "items.json": _build_item_mapping(assets.tables, translations), + "achievements.json": _build_achievement_mapping( + assets.tables["achievements"], translations + ), } validate_mappings(output) additions: dict[str, list[str]] = {} updates: dict[str, list[str]] = {} deletions: dict[str, list[str]] = {} - for filename in REWARD_MAPPING_FILES: + for filename in MAPPING_FILES: old = current[filename] new = output[filename] additions[filename] = sorted(new.keys() - old.keys(), key=str.casefold) @@ -169,7 +173,7 @@ def build_mapping_update( def write_update(result: MappingUpdateResult, directory: Path) -> None: directory.mkdir(parents=True, exist_ok=True) - for filename in REWARD_MAPPING_FILES: + for filename in MAPPING_FILES: _atomic_write(directory / filename, _dump_mapping(result.mappings[filename])) report_text = json.dumps(result.report, ensure_ascii=False, indent=2, sort_keys=True) + "\n" _atomic_write(directory / "mapping-update-report.json", report_text) @@ -180,11 +184,11 @@ def apply_update(result: MappingUpdateResult, directory: Path) -> None: directory.mkdir(parents=True, exist_ok=True) originals: dict[Path, bytes | None] = {} written: list[Path] = [] - for filename in REWARD_MAPPING_FILES: + for filename in MAPPING_FILES: path = directory / filename originals[path] = path.read_bytes() if path.exists() else None try: - for filename in REWARD_MAPPING_FILES: + for filename in MAPPING_FILES: path = directory / filename _atomic_write(path, _dump_mapping(result.mappings[filename])) written.append(path) @@ -199,11 +203,11 @@ def apply_update(result: MappingUpdateResult, directory: Path) -> None: def validate_mappings(mappings: dict[str, dict[str, dict[str, Any]]]) -> None: - if set(mappings) != set(REWARD_MAPPING_FILES): - raise MappingUpdateError(f"mapping set must be exactly {REWARD_MAPPING_FILES}") + if set(mappings) != set(MAPPING_FILES): + raise MappingUpdateError(f"mapping set must be exactly {MAPPING_FILES}") locations: dict[str, str] = {} folded: dict[str, str] = {} - for filename in REWARD_MAPPING_FILES: + for filename in MAPPING_FILES: entries = mappings[filename] if not isinstance(entries, dict): raise MappingUpdateError(f"{filename} must contain an object") @@ -232,6 +236,33 @@ def validate_mappings(mappings: dict[str, dict[str, dict[str, Any]]]) -> None: raise MappingUpdateError(f"{filename}:{item_id}.type must be item or cosmetic") if rank not in {"S", "A", "B"}: raise MappingUpdateError(f"{filename}:{item_id}.rank must be S, A, or B") + if filename == "achievements.json": + _validate_achievement_meta(filename, item_id, meta) + + +def _validate_achievement_meta(filename: str, item_id: str, meta: dict[str, Any]) -> None: + for field in ("description", "category", "quality"): + if not isinstance(meta.get(field), str) or not meta[field].strip(): + raise MappingUpdateError( + f"{filename}:{item_id}.{field} must be a non-empty string" + ) + target = meta.get("target") + if not isinstance(target, (int, float)) or isinstance(target, bool) or target < 0: + raise MappingUpdateError(f"{filename}:{item_id}.target must be a non-negative number") + rewards = meta.get("rewards") + if not isinstance(rewards, list): + raise MappingUpdateError(f"{filename}:{item_id}.rewards must be a list") + for index, reward in enumerate(rewards): + if ( + not isinstance(reward, dict) + or not isinstance(reward.get("item_id"), str) + or not reward["item_id"] + or not isinstance(reward.get("amount"), (int, float)) + or isinstance(reward.get("amount"), bool) + ): + raise MappingUpdateError( + f"{filename}:{item_id}.rewards[{index}] is invalid" + ) def _extract_rows(document: Any, relative_path: str) -> dict[str, Any]: @@ -289,8 +320,20 @@ def _build_item_mapping( candidate_ids = _reward_candidate_ids(tables) for candidate_id in candidate_ids: folded = candidate_id.casefold() - if candidate_id.isdigit() or folded.startswith("fork_") or folded.startswith("characterawaken_"): + if ( + candidate_id.isdigit() + or folded.startswith("fork_") + or folded.startswith("characterawaken_") + ): continue + if folded.startswith("fashion_vehicle_"): + illustration = tables["illustrations"].get(candidate_id) + if illustration is not None: + vehicle_meta = _normalise_vehicle_livery(candidate_id, illustration, translations) + if vehicle_meta is None: + continue + result[candidate_id] = vehicle_meta + continue sources = (inventory, capital, appearances, vehicle_inventory) match = next((source.get(folded) for source in sources if folded in source), None) if match is None: @@ -307,6 +350,87 @@ def _build_item_mapping( return dict(sorted(result.items(), key=lambda pair: (pair[0].casefold(), pair[0]))) +def _normalise_vehicle_livery( + item_id: str, + row: Any, + translations: dict[str, list[tuple[str, str]]], +) -> dict[str, str] | None: + """Resolve a livery from its illustration, ignoring mismatched placeholder rows.""" + if not isinstance(row, dict): + raise MappingUpdateError(f"NTE_Assets row {item_id} must be an object") + head_icon = row.get("HeadIcon") + asset_path = head_icon.get("AssetPathName") if isinstance(head_icon, dict) else None + if not isinstance(asset_path, str) or not asset_path: + raise MappingUpdateError(f"NTE_Assets vehicle livery row {item_id} has no HeadIcon") + icon_id = asset_path.rsplit("/", 1)[-1].split(".", 1)[0] + if icon_id.casefold() != item_id.casefold(): + return None + item_name = row.get("ItemName_Override") + if not isinstance(item_name, dict): + raise MappingUpdateError(f"NTE_Assets vehicle livery row {item_id} has no ItemName_Override") + return { + "type": "cosmetic", + "name": _translate_name(item_id, item_name, translations).strip(), + "rank": "S", + } + + +def _build_achievement_mapping( + rows: dict[str, Any], + translations: dict[str, list[tuple[str, str]]], +) -> dict[str, dict[str, Any]]: + result = {} + for achievement_id in sorted(rows, key=lambda value: (value.casefold(), value)): + row = rows[achievement_id] + if not isinstance(row, dict): + raise MappingUpdateError( + f"NTE_Assets achievement row {achievement_id} must be an object" + ) + title = row.get("TitleId") + description = row.get("ContentID") + if not isinstance(title, dict) or not isinstance(description, dict): + raise MappingUpdateError( + f"NTE_Assets achievement row {achievement_id} has no title or description" + ) + target = row.get("Amout") + if not isinstance(target, (int, float)) or isinstance(target, bool): + raise MappingUpdateError( + f"NTE_Assets achievement row {achievement_id} has invalid Amout" + ) + main_type = row.get("AchievementMainType") + quality = row.get("Quality") + if not isinstance(main_type, str) or "MainType" not in main_type: + raise MappingUpdateError( + f"NTE_Assets achievement row {achievement_id} has invalid main type" + ) + if not isinstance(quality, str) or "::" not in quality: + raise MappingUpdateError( + f"NTE_Assets achievement row {achievement_id} has invalid quality" + ) + rewards = [] + for index, award in enumerate(row.get("AwardInfo", [])): + if not isinstance(award, dict): + raise MappingUpdateError( + f"NTE_Assets achievement row {achievement_id} award {index} is invalid" + ) + item_id = award.get("ItemID") + amount = award.get("Amount") + if not isinstance(item_id, str) or not item_id or not isinstance(amount, (int, float)): + raise MappingUpdateError( + f"NTE_Assets achievement row {achievement_id} award {index} is invalid" + ) + rewards.append({"item_id": item_id, "amount": amount}) + result[achievement_id] = { + "name": _translate_name(achievement_id, title, translations), + "description": _translate_name(achievement_id, description, translations), + "category": main_type.rsplit("MainType", 1)[-1].casefold(), + "quality": quality.rsplit("::", 1)[-1].casefold(), + "target": target, + "rewards": rewards, + } + return result + + def _reward_candidate_ids(tables: dict[str, dict[str, Any]]) -> list[str]: """Return rewards used by ordinary Gacha and every Mystery Box rotation.""" candidates: dict[str, str] = { diff --git a/src/nte_history_exporter/mappings.py b/src/nte_history_exporter/mappings.py index 27213a1..5ce00ab 100644 --- a/src/nte_history_exporter/mappings.py +++ b/src/nte_history_exporter/mappings.py @@ -14,8 +14,15 @@ def load_mapping_file(filename: str) -> dict[str, Any]: ARC_META: dict[str, dict[str, Any]] = load_mapping_file("arcs.json") +ARC_META_BY_CASEFOLD = { + arc_id.casefold(): (arc_id, meta) for arc_id, meta in ARC_META.items() +} CHARACTERS: dict[str, dict[str, Any]] = load_mapping_file("characters.json") ITEMS: dict[str, dict[str, Any]] = load_mapping_file("items.json") +ACHIEVEMENTS: dict[str, dict[str, Any]] = load_mapping_file("achievements.json") +ACHIEVEMENTS_BY_CASEFOLD = { + achievement_id.casefold(): meta for achievement_id, meta in ACHIEVEMENTS.items() +} # Reward keys in Monopoly history packets are the reward id string itself # (ASCII*4 with carry, see decoder.protocol.decode_reward_key), so all reward @@ -32,3 +39,6 @@ for _character_id, _meta in CHARACTERS.items(): } for _item_id, _meta in ITEMS.items(): REWARDS_BY_ID[_item_id] = {"type": _meta["type"], "id": _item_id, "name": _meta["name"], "rank": _meta.get("rank")} +REWARDS_BY_CASEFOLD = { + reward_id.casefold(): reward for reward_id, reward in REWARDS_BY_ID.items() +} diff --git a/tests/fixtures/nte_assets/DataTable/DT_AchievementConfigInfo.json b/tests/fixtures/nte_assets/DataTable/DT_AchievementConfigInfo.json new file mode 100644 index 0000000..ff68862 --- /dev/null +++ b/tests/fixtures/nte_assets/DataTable/DT_AchievementConfigInfo.json @@ -0,0 +1,22 @@ +[ + { + "Rows": { + "battle_25": { + "Amout": 50, + "AchievementMainType": "AchievementMainType::MainTypeFight", + "AwardInfo": [ + {"ItemID": "Annulith", "Amount": 10} + ], + "TitleId": { + "TableId": "/Game/Text/ST_Achievement.ST_Achievement", + "Key": "battle_25_Title" + }, + "ContentID": { + "TableId": "/Game/Text/ST_Achievement.ST_Achievement", + "Key": "battle_25_Content" + }, + "Quality": "EHTAchievementQuality::High" + } + } + } +] diff --git a/tests/fixtures/nte_assets/DataTable/Gacha/GachaIllustrate.json b/tests/fixtures/nte_assets/DataTable/Gacha/GachaIllustrate.json index bd2fdf3..9e28621 100644 --- a/tests/fixtures/nte_assets/DataTable/Gacha/GachaIllustrate.json +++ b/tests/fixtures/nte_assets/DataTable/Gacha/GachaIllustrate.json @@ -1 +1 @@ -[{"Rows":{"1003":{},"1099":{},"fork_alpha":{},"fork_new":{},"DIceNormal":{},"Dice_ticket_03":{},"Fashion_glide_2000":{},"Characterawaken_1003":{}}}] +[{"Rows":{"1003":{},"1099":{},"fork_alpha":{},"fork_new":{},"DIceNormal":{},"Dice_ticket_03":{},"Fashion_glide_2000":{},"Characterawaken_1003":{},"Fashion_vehicle_1010_V008":{"HeadIcon":{"AssetPathName":"/Game/UI/UI_Icon/Item/Fashion_vehicle_1010_V008.Fashion_vehicle_1010_V008"},"ItemName_Override":{"TableId":"/Game/Text/ST_VehicleData.ST_VehicleData","Key":"vehicle_1010_livery"}},"Fashion_vehicle_1072_V006":{"HeadIcon":{"AssetPathName":"/Game/UI/UI_Icon/Item/Fashion_vehicle_1036_V043.Fashion_vehicle_1036_V043"},"ItemName_Override":{"TableId":"/Game/Text/ST_VehicleData.ST_VehicleData","Key":"vehicle_placeholder_livery"}}}}] diff --git a/tests/fixtures/nte_assets/Localization/en/game.json b/tests/fixtures/nte_assets/Localization/en/game.json index 5bda83c..97e2667 100644 --- a/tests/fixtures/nte_assets/Localization/en/game.json +++ b/tests/fixtures/nte_assets/Localization/en/game.json @@ -1,4 +1,8 @@ { + "ST_Achievement": { + "battle_25_Title": "Devil Within II", + "battle_25_Content": "Trigger Hexed ×50." + }, "ST_Player": { "character_1003": "Old Character Renamed", "character_1099": "New Character" @@ -14,5 +18,9 @@ }, "ST_Appearance": { "glider_2000": "Inventory Glider" + }, + "ST_VehicleData": { + "vehicle_1010_livery": "Tiger Incoming! - Livery", + "vehicle_placeholder_livery": "Sandy Mirage - Livery" } } diff --git a/tests/test_achievement.py b/tests/test_achievement.py new file mode 100644 index 0000000..9145c1d --- /dev/null +++ b/tests/test_achievement.py @@ -0,0 +1,37 @@ +import struct +import unittest + +from nte_history_exporter.decoder.achievement import ( + ACHIEVEMENT_RECORD_MARKER, + extract_achievement_ids, + extract_achievement_records, + reassemble_tcp_segments, +) + + +class AchievementPacketTests(unittest.TestCase): + def test_extracts_only_completed_achievements(self): + header = ACHIEVEMENT_RECORD_MARKER + b"\0" * 4 + struct.pack(" int: f"{changes['deletions']} deletions" ) if args.apply: - print("applied: mappings/arcs.json, mappings/characters.json, mappings/items.json") + print( + "applied: mappings/arcs.json, mappings/characters.json, " + "mappings/items.json, mappings/achievements.json" + ) return 1 if args.check and result.change_count else 0