commit fb009549540c5bb27e8c24eb807cb37834e27b3d Author: caoimhinr Date: Tue Jun 2 23:33:06 2026 +0200 Initial scaffold: Nakama backend, Godot client, content schema Sets up the Step 1 (Project Foundation) and Step 2 (Content Schemas) scaffold from the implementation sequence. Includes Docker Compose stack, Nakama TypeScript module stubs, PostgreSQL migration, Godot 4 project shell with scene stubs and NakamaClient singleton, YAML content schemas for all entity types, and example data for 3 characters, 2 jobs, and all 7 core resources. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d7759f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Environment +.env +*.env.local + +# Godot +godot/.godot/ +godot/export/ +*.import +*.translation + +# Python +__pycache__/ +*.pyc +.venv/ +venv/ + +# Node / TypeScript (Nakama modules build output) +nakama/node_modules/ +nakama/dist/ +nakama/*.js +!nakama/modules/*.js + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..8307bce --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# Xyvera + +Cozy management gacha with meaningful guild strategy. + +This repository is the proof-of-concept scaffold. It covers Steps 1–2 of the +[implementation sequence](docs/ — see DOCS vault) and gives a runnable local +stack to build the rest of the PoC against. + +## Architecture + +| Layer | Technology | Purpose | +|---|---|---| +| Client | Godot 4.3 | UI, scenes, local state cache | +| Game backend | Nakama 3.22 | Auth, gacha, inventory, village RPCs | +| Nakama DB | CockroachDB 23.2 | Nakama internal state | +| Game DB | PostgreSQL 16 | Economy ledger, roster, content cache | + +## Prerequisites + +- Docker and Docker Compose v2 +- Godot 4.3+ with the [GDNakama plugin](https://github.com/heroiclabs/nakama-godot) +- Python 3.10+ (for content validation tooling) +- Node.js 20+ (for Nakama TypeScript module compilation) + +## Start the dev stack + +```bash +cd /path/to/xyvera + +# 1. Copy the example env file and adjust if needed +cp infra/.env.example .env + +# 2. Start all services +docker compose up -d + +# 3. Verify they are running +docker compose ps +``` + +Services after startup: + +| Service | URL | +|---|---| +| Nakama console | http://localhost:7351 | +| Nakama API | http://localhost:7350 | +| CockroachDB admin | http://localhost:8080 | +| PostgreSQL | localhost:5432 | + +## Run migrations + +PostgreSQL migrations in `migrations/` are applied automatically by the +`postgres` container on first boot via `docker-entrypoint-initdb.d`. + +To apply them manually (e.g. after resetting the volume): + +```bash +docker compose exec postgres psql -U xyvera -d xyvera -f /migrations/001_initial_schema.sql +``` + +To reset the database entirely (destroys all data): + +```bash +docker compose down -v +docker compose up -d +``` + +## Build and deploy Nakama modules + +Nakama TypeScript modules must be compiled to JavaScript before Nakama loads them. + +```bash +cd nakama +npm install +npm run build +``` + +The compiled `.js` files land in `nakama/modules/` alongside the source. +The `nakama` service mounts this directory, so changes take effect after a +container restart: + +```bash +docker compose restart nakama +``` + +## Validate content files + +```bash +pip install pyyaml jsonschema +python3 tools/validate_content.py +``` + +This checks every file under `content/data/` against the matching schema +in `content/schema/`. Run this before committing content changes. + +## Open in Godot + +1. Open Godot 4.3+. +2. Import the project at `godot/`. +3. Install the GDNakama plugin (Asset Library or manually from the repo above). +4. Enable the plugin in Project → Project Settings → Plugins. +5. Run the project (`F5`). The Main Menu will attempt to connect to `localhost:7349`. + +The default connection target is `localhost:7349`, matching the Docker Compose stack. +To connect to a different host, edit `SERVER_HOST` in `godot/src/nakama/NakamaClient.gd`. + +## Project structure + +``` +xyvera/ +├── docker-compose.yml Local dev stack (Nakama + CockroachDB + Postgres) +├── nakama/ +│ ├── modules/ TypeScript server-side modules (compiled to JS) +│ │ ├── main.ts Entry point — registers all RPCs +│ │ ├── auth.ts Account seeding +│ │ ├── gacha.ts Pull execution (stub — implement PR 5) +│ │ ├── inventory.ts Inventory and resources +│ │ ├── progression.ts Level / ascension / skill (stub — PR 6) +│ │ └── village.ts Assignment + manual work (stub — PR 5/8) +│ ├── package.json +│ └── tsconfig.json +├── content/ +│ ├── schema/ YAML schemas for all content types +│ │ ├── character.yaml +│ │ ├── job.yaml +│ │ ├── resource.yaml +│ │ ├── banner.yaml +│ │ └── event.yaml +│ └── data/ +│ ├── characters/ 3 example characters (Lyra, Ember, Sable) +│ ├── jobs/ 2 example jobs (Lumber Camp, Research Post) +│ └── resources/ 7 core resource definitions +├── migrations/ +│ └── 001_initial_schema.sql Players, ledger, roster, pulls, pity, content cache +├── godot/ +│ ├── project.godot +│ ├── assets/placeholders/ +│ └── src/ +│ ├── nakama/ +│ │ └── NakamaClient.gd Singleton wrapper for all server calls +│ ├── systems/ +│ │ └── GameState.gd Global state cache (AutoLoad) +│ └── screens/ +│ ├── MainMenu.gd/.tscn +│ ├── Village.gd/.tscn Home hub + manual work button +│ ├── GachaPull.gd/.tscn +│ └── Inventory.gd/.tscn +├── tools/ +│ └── validate_content.py Content schema validator +└── infra/ + └── .env.example +``` + +## Implementation sequence + +This scaffold covers **Step 1 (Project Foundation)** and part of **Step 2 (Content Schemas)** from the spec. + +Next PRs to implement: + +| PR | Scope | +|---|---| +| PR 3 | Player profile model, secure auth | +| PR 4 | Currency ledger and inventory API | +| **PR 5** | **Manual work loop** — highest PoC priority | +| PR 6 | Character progression (level, ascend, skills) | +| PR 8 | Village passive production and assignment | +| PR 9 | Event calendar framework | + +The manual work loop (PR 5 / `village/manual_work` RPC) is the most important +system to validate. The spec is explicit: if that mechanic fails to feel +rewarding, the entire concept should be reconsidered before more content is built. + +## Design decisions made in this scaffold + +The following details were not fully specified in the design notes and were +decided here. Record any changes in the relevant schema or module: + +- **Pity defaults**: soft pity at 70 pulls, hard pity at 90 (similar to Genshin/HSR; tune via banner content YAML). +- **Manual work cost**: 5 mana per action; 3 full-yield actions per day, 50% diminishing returns thereafter. All constants in `village.ts`. +- **Passive cap**: 8 hours accumulation cap before collection is required. Prevents AFK advantage without real engagement. +- **Separate Postgres from CockroachDB**: Nakama requires CockroachDB and manages its own schema there. The game economy, roster, and content live in a separate Postgres instance for clean migration ownership. +- **Rarity tier**: R / SR / SSR / UR four-tier model for PoC per the spec recommendation (full branded naming ladder deferred to production). +- **Wood as the bottleneck**: the first manual work target is wood, gating early construction upgrades. Stone is the secondary scarcity axis once construction buildings exist. diff --git a/content/data/characters/ember_ashvale.yaml b/content/data/characters/ember_ashvale.yaml new file mode 100644 index 0000000..61cb1b4 --- /dev/null +++ b/content/data/characters/ember_ashvale.yaml @@ -0,0 +1,80 @@ +# Ember Ashvale / Ezra Ashvale +# SR | Glass Cannon | Lumberjack +# The player's first realistic gacha pull in the standard banner. +# SR rarity — reliable specialist who makes the early economy viable. +# Designed to demonstrate that non-SSR characters stay meaningfully useful: +# her lumberjack bonus feeds the wood bottleneck that the manual work system +# is built around. + +$schema: "xyvera/content-schema/character/v1" + +id: ember_ashvale + +name: + female: "Ember Ashvale" + male: "Ezra Ashvale" + +rarity: SR +combat_role: glass_cannon +profession: lumberjack +region_origin: heartwood_vale +element: fire +personality: energetic_optimist + +base_stats: + hp: 680 + attack: 195 + defense: 60 + speed: 115 + crit_rate: 0.10 + crit_damage: 1.8 + +skills: + - id: blaze_cut + name: "Blaze Cut" + type: active + description: > + Deals 220% ATK as fire damage to one enemy. If the target is on fire (burning + status), also deals 80% ATK bonus damage. + max_level: 5 + upgrade_cost_per_level: + knowledge: 25 + gold: 180 + + - id: sawdust_flurry + name: "Sawdust Flurry" + type: passive + description: > + Passive: each time Ember defeats an enemy, she gains +8% attack speed for + 2 turns (stacks up to 3 times). + max_level: 3 + upgrade_cost_per_level: + knowledge: 15 + gold: 120 + + - id: quick_harvest + name: "Quick Harvest" + type: passive + description: > + Village passive: when assigned to a Lumber Camp slot, adds a 20% chance per + hour to produce a bonus burst of wood equal to 30 minutes of base output. + This bonus chance does not require manual input. + max_level: 5 + upgrade_cost_per_level: + knowledge: 10 + gold: 80 + +suitability_bonuses: + primary_profession_multiplier: 1.5 + secondary_professions: + - farmer # comfortable outdoors + +bond_hooks: + - cheerful + - mentor_target + - competitive + +lore_summary: > + An apprentice woodcutter who followed a glowing deer into a forest and came out + somewhere entirely different. She's still looking for the deer, but in the meantime + the logs aren't going to cut themselves. diff --git a/content/data/characters/lyra_stoneheart.yaml b/content/data/characters/lyra_stoneheart.yaml new file mode 100644 index 0000000..2f3306d --- /dev/null +++ b/content/data/characters/lyra_stoneheart.yaml @@ -0,0 +1,77 @@ +# Lyra Stoneheart / Leon Stoneheart +# SSR | Tank | Miner +# Starter-region character. Designed to anchor the early assignment economy: +# she is the primary miner for the PoC and functions as the front-row tank +# in combat. Her kit is intentionally simple — the first character the player +# will level up and use to validate both the assignment and combat systems. + +$schema: "xyvera/content-schema/character/v1" + +id: lyra_stoneheart + +name: + female: "Lyra Stoneheart" + male: "Leon Stoneheart" + +rarity: SSR +combat_role: tank +profession: miner +region_origin: heartwood_vale +element: earth +personality: stoic_protector + +base_stats: + hp: 1200 + attack: 80 + defense: 160 + speed: 70 + crit_rate: 0.04 + crit_damage: 1.4 + +skills: + - id: stone_resolve + name: "Stone Resolve" + type: passive + description: > + Passive: when HP drops below 40%, gain a shield equal to 15% of max HP. + Triggers once per combat. + max_level: 5 + upgrade_cost_per_level: + knowledge: 20 + gold: 150 + + - id: seismic_slam + name: "Seismic Slam" + type: ultimate + description: > + Deals 180% ATK as earth damage to all front-row enemies and reduces their + defense by 20% for 2 turns. + max_level: 5 + upgrade_cost_per_level: + knowledge: 40 + gold: 300 + + - id: miners_grit + name: "Miner's Grit" + type: passive + description: > + Village passive: when assigned to a Mining slot, stone output gains an + additional +10% per skill level on top of the suitability multiplier. + max_level: 5 + upgrade_cost_per_level: + knowledge: 15 + gold: 100 + +suitability_bonuses: + primary_profession_multiplier: 1.6 # slightly above default 1.5 due to Miner's Grit + secondary_professions: + - construction # knows how to work with stone + +bond_hooks: + - loyalty + - protector + - stoic + +lore_summary: > + A former quarry foreman who arrived in this world mid-shift, still gripping her pickaxe. + She doesn't talk much about home, but she builds like she's trying to recreate it. diff --git a/content/data/characters/sable_quillworth.yaml b/content/data/characters/sable_quillworth.yaml new file mode 100644 index 0000000..44a54de --- /dev/null +++ b/content/data/characters/sable_quillworth.yaml @@ -0,0 +1,79 @@ +# Sable Quillworth / Sebastian Quillworth +# SR | Support | Researcher +# The knowledge-economy anchor. Researchers unlock skill upgrades and building +# improvements, so this character is critical to demonstrating that the +# profession assignment system has strategic depth beyond raw resource output. +# Support role in combat — placed in the backrow, buffs the team. + +$schema: "xyvera/content-schema/character/v1" + +id: sable_quillworth + +name: + female: "Sable Quillworth" + male: "Sebastian Quillworth" + +rarity: SR +combat_role: support +profession: researcher +region_origin: heartwood_vale +element: "null" +personality: methodical_curious + +base_stats: + hp: 760 + attack: 95 + defense: 110 + speed: 90 + crit_rate: 0.05 + crit_damage: 1.5 + +skills: + - id: analytical_boost + name: "Analytical Boost" + type: active + description: > + Increases all allies' ATK by 18% and DEF by 12% for 3 turns. At skill + level 3+, also removes one debuff from a random ally. + max_level: 5 + upgrade_cost_per_level: + knowledge: 30 + gold: 200 + + - id: field_notes + name: "Field Notes" + type: passive + description: > + Passive: at the start of each combat round, Sable gains 1 knowledge + (village resource) per enemy still alive (capped at 5 per round, 20 per combat). + Knowledge gained this way is credited to the player's balance at combat end. + max_level: 3 + upgrade_cost_per_level: + knowledge: 20 + gold: 150 + + - id: deep_study + name: "Deep Study" + type: passive + description: > + Village passive: when assigned to a Research Post, knowledge output is + increased by 30% and skill upgrade costs for all characters are reduced by 5%. + The cost reduction stacks additively if multiple researchers are assigned. + max_level: 5 + upgrade_cost_per_level: + knowledge: 35 + gold: 250 + +suitability_bonuses: + primary_profession_multiplier: 1.5 + secondary_professions: [] # purely academic; no secondary bonus + +bond_hooks: + - intellectual + - reserved + - hidden_warmth + +lore_summary: > + A librarian's assistant who was cataloguing a collection of supposedly mundane travel + journals when the pages started describing places she'd never heard of — and then + she was in one of them. She brought three notebooks and has already filled two. diff --git a/content/data/jobs/lumber_camp.yaml b/content/data/jobs/lumber_camp.yaml new file mode 100644 index 0000000..48208b6 --- /dev/null +++ b/content/data/jobs/lumber_camp.yaml @@ -0,0 +1,33 @@ +# Lumber Camp — Lumberjack job slot +# The primary manual work target in the PoC. +# Wood is the designed scarcity bottleneck: it gates construction upgrades +# and several early character ascension recipes. Players can passively +# accumulate wood here, or use the manual_work action for burst gains. +# manual_work_eligible: true makes this the focus of the micro-management demo. + +$schema: "xyvera/content-schema/job/v1" + +id: lumber_camp +name: "Lumber Camp" +description: > + Workers harvest timber from the forest edge of Heartwood Vale. Wood is the + backbone of early construction and a bottleneck players will want to push past. + Assign a lumberjack here for full output, or use the manual work action to + chop extra wood yourself. + +profession: lumberjack +output_resource: wood +base_output_per_hour: 12.0 + +secondary_output: + resource: gold + rate_fraction: 0.15 # small gold bonus representing sold scrap timber + +max_slots: 3 + +unlock_condition: + building_level: 0 # available from the start + +manual_work_eligible: true # this is the PoC manual micro-management target + +passive_cap_hours: 8.0 diff --git a/content/data/jobs/research_post.yaml b/content/data/jobs/research_post.yaml new file mode 100644 index 0000000..699fce0 --- /dev/null +++ b/content/data/jobs/research_post.yaml @@ -0,0 +1,32 @@ +# Research Post — Researcher job slot +# Produces knowledge, which is consumed by skill upgrades and building research. +# Knowledge is the second scarcity axis (after wood) — the design intent is that +# players must choose between levelling characters fast (knowledge) versus +# building the village faster (wood/stone). +# manual_work_eligible is false here; manual focus stays on wood for PoC clarity. + +$schema: "xyvera/content-schema/job/v1" + +id: research_post +name: "Research Post" +description: > + Scholars and curious minds study the strange phenomena of this world. + Knowledge produced here is consumed by skill upgrades, building improvements, + and eventually advanced expedition planning. + +profession: researcher +output_resource: knowledge +base_output_per_hour: 6.0 + +secondary_output: + resource: experience + rate_fraction: 0.25 # minor experience trickle — characters learn while working + +max_slots: 2 + +unlock_condition: + building_level: 1 # requires the village hub to be upgraded once + +manual_work_eligible: false + +passive_cap_hours: 8.0 diff --git a/content/data/resources/core_resources.yaml b/content/data/resources/core_resources.yaml new file mode 100644 index 0000000..fd937ad --- /dev/null +++ b/content/data/resources/core_resources.yaml @@ -0,0 +1,149 @@ +# core_resources.yaml — definitions for all seven core economy resources +# These are the definite resource set from the concept specification. + +--- +$schema: "xyvera/content-schema/resource/v1" +id: gold +name: "Gold" +description: "General-purpose soft currency earned from trade, jobs, and combat rewards." +category: soft_currency +is_spendable: true +is_capped: false +icon: "icons/resources/gold.png" +color_hex: "#F4C542" +sources: + - "Village jobs with gold output" + - "Combat stage clear rewards" + - "Event milestone rewards" + - "Selling excess items" +sinks: + - "Character level-up costs" + - "Skill upgrade costs" + - "Building upgrades" + - "Shop purchases" + +--- +$schema: "xyvera/content-schema/resource/v1" +id: wood +name: "Wood" +description: > + Primary construction material. The early bottleneck resource. + Assign lumberjacks to the Lumber Camp for passive gain, or use the + manual work action for burst output. +category: soft_currency +is_spendable: true +is_capped: true +carry_cap: 9999 +icon: "icons/resources/wood.png" +color_hex: "#8B5E3C" +sources: + - "Lumber Camp passive output" + - "Manual wood-chopping action (burst yield, daily cap applies)" + - "Event rewards" +sinks: + - "Building construction and upgrades" + - "Character ascension material crafting" + - "Equipment crafting (deferred)" + +--- +$schema: "xyvera/content-schema/resource/v1" +id: stone +name: "Stone" +description: "Durable building material mined from rocky outcroppings." +category: soft_currency +is_spendable: true +is_capped: true +carry_cap: 9999 +icon: "icons/resources/stone.png" +color_hex: "#9E9E9E" +sources: + - "Mining Site passive output" + - "Event rewards" +sinks: + - "Advanced building upgrades" + - "Ascension material crafting" + +--- +$schema: "xyvera/content-schema/resource/v1" +id: knowledge +name: "Knowledge" +description: > + Accumulated learning that fuels skill upgrades and research improvements. + The second scarcity axis after wood — players must balance knowledge + spending between skill upgrades and building research. +category: knowledge +is_spendable: true +is_capped: true +carry_cap: 5000 +icon: "icons/resources/knowledge.png" +color_hex: "#7B68EE" +sources: + - "Research Post passive output" + - "Combat passive skill (Sable's Field Notes)" + - "Event milestone rewards" +sinks: + - "Character skill upgrades" + - "Building research" + - "Expedition unlocks (deferred)" + +--- +$schema: "xyvera/content-schema/resource/v1" +id: experience +name: "Experience" +description: "Character growth material gained from combat, jobs, and events." +category: soft_currency +is_spendable: true +is_capped: false +icon: "icons/resources/experience.png" +color_hex: "#4CAF50" +sources: + - "Combat stage completion" + - "Research Post secondary output" + - "Assignment mastery milestone rewards" +sinks: + - "Character level-up (primary cost alongside gold)" + +--- +$schema: "xyvera/content-schema/resource/v1" +id: mana +name: "Mana" +description: > + Ambient magical energy. Consumed by the manual work action and some + skill activations. Regenerates slowly over time. +category: stamina +is_spendable: true +is_capped: true +carry_cap: 200 +icon: "icons/resources/mana.png" +color_hex: "#29B6F6" +sources: + - "Passive regen (5 mana per hour)" + - "Event rewards" + - "Diamond purchase (deferred — monetisation not active in PoC)" +sinks: + - "Manual work action (5 mana per use)" + - "Future expedition actions (deferred)" + +--- +$schema: "xyvera/content-schema/resource/v1" +id: diamond +name: "Diamond" +description: > + Premium hard currency. Obtainable free-to-play through events, milestones, + and logins. Can also be purchased. Used primarily for gacha pulls. + Spending should never be required for core progression per the F2P spec. +category: premium +is_spendable: true +is_capped: false +icon: "icons/resources/diamond.png" +color_hex: "#00E5FF" +sources: + - "New account seed grant (100)" + - "Event milestone rewards" + - "Achievement unlocks" + - "Login streak bonuses" + - "Purchase (deferred — monetisation not active in PoC)" +sinks: + - "Standard banner pulls (cost TBD per banner definition)" + - "Featured banner pulls" + - "Shop convenience purchases (deferred)" diff --git a/content/schema/banner.yaml b/content/schema/banner.yaml new file mode 100644 index 0000000..e4f51cd --- /dev/null +++ b/content/schema/banner.yaml @@ -0,0 +1,121 @@ +# banner.yaml — schema definition for gacha banner definitions +# +# All rate tables are content-driven. No hardcoded pull rates in server code. +# Banner end dates and pity rules must be visible to players per design spec. + +$schema: "xyvera/content-schema/banner/v1" + +type: object +required: + - id + - name + - type + - pull_cost + - pool + - pity + +properties: + + id: + type: string + pattern: "^[a-z][a-z0-9_]*$" + + name: + type: string + + type: + type: string + enum: [standard, featured, event] + description: > + standard = permanent evergreen with wishlist; + featured = region or character banner with up/down rate; + event = limited mega-event with spark currency. + + pull_cost: + type: object + required: [currency, single, multi] + properties: + currency: + type: string + description: "Resource ID used to pull. Usually 'diamond' or a banner-specific ticket." + single: + type: integer + minimum: 1 + multi: + type: integer + minimum: 1 + description: "Cost for a 10-pull. Typically single * 10 minus one free pull." + + pool: + type: array + description: "All characters eligible from this banner." + items: + type: object + required: [character_id, rarity, base_weight] + properties: + character_id: + type: string + rarity: + type: string + enum: [R, SR, SSR, UR] + base_weight: + type: number + minimum: 0.0 + description: "Relative weight before pity modifiers. Higher = more common." + is_featured: + type: boolean + default: false + + pity: + type: object + required: [soft_pity_start, hard_pity, guaranteed_rarity] + properties: + soft_pity_start: + type: integer + minimum: 1 + description: "Pull count at which SSR/UR rate starts increasing toward hard pity." + hard_pity: + type: integer + minimum: 1 + description: "Pull count that guarantees at least guaranteed_rarity." + guaranteed_rarity: + type: string + enum: [SR, SSR, UR] + featured_guarantee: + type: object + description: "50/50 featured character guarantee rules." + properties: + rate: + type: number + minimum: 0.0 + maximum: 1.0 + description: "Chance featured character is selected when SSR hits." + guaranteed_after_loss: + type: boolean + default: true + description: "If false on the 50/50, next SSR is guaranteed featured." + spark_currency: + type: object + description: "Only applies to event-type banners." + properties: + currency_id: + type: string + spark_cost: + type: integer + description: "Amount of spark currency required to select a featured character." + + active_from: + type: string + format: date-time + description: "ISO 8601 UTC datetime. Null means active from server start." + + active_until: + type: string + format: date-time + description: "ISO 8601 UTC datetime. Null means permanent." + + rerun_policy: + type: string + enum: [permanent, will_rerun, no_rerun_planned, collaboration_locked] + default: permanent + description: "Displayed to players per the fair-visibility design pillar." diff --git a/content/schema/character.yaml b/content/schema/character.yaml new file mode 100644 index 0000000..62112a9 --- /dev/null +++ b/content/schema/character.yaml @@ -0,0 +1,165 @@ +# character.yaml — schema definition for Xyvera character content files +# +# Every file under content/data/characters/ must conform to this schema. +# Run `tools/validate_content.py` to check all data files against schemas. +# +# Design notes from spec: +# - Rarity: R / SR / SSR / UR (four-tier PoC model; full ladder naming deferred) +# - Combat roles: recon, sabotage, healer, support, tank, glass_cannon +# - Profession roles: miner, lumberjack, researcher, construction, farmer, trainer +# - Male and female presentation variants are art/naming only; mechanics identical +# - No duplicate-dependency model: progression uses universal materials, bonds, +# and assignment mastery + +$schema: "xyvera/content-schema/character/v1" + +type: object +required: + - id + - name + - rarity + - combat_role + - profession + - base_stats + - skills + - suitability_bonuses + +properties: + + id: + type: string + description: "Unique character identifier. Snake_case. Shared between male/female variants." + pattern: "^[a-z][a-z0-9_]*$" + example: "lyra_stoneheart" + + name: + type: object + description: "Display names for each presentation variant." + required: [female] + properties: + female: + type: string + example: "Lyra Stoneheart" + male: + type: string + example: "Leon Stoneheart" + additionalProperties: false + + rarity: + type: string + enum: [R, SR, SSR, UR] + description: "R = common filler, SR = reliable specialist, SSR = premium, UR = rare/prestige" + + combat_role: + type: string + enum: [recon, sabotage, healer, support, tank, glass_cannon] + + profession: + type: string + enum: [miner, lumberjack, researcher, construction, farmer, trainer] + description: "Primary village assignment profession. Used to determine suitability bonuses." + + region_origin: + type: string + description: "World region this character is associated with. Affects banner pools and event tags." + example: "heartwood_vale" + + element: + type: string + enum: [fire, water, earth, wind, light, shadow, null] + description: "Optional elemental archetype. 'null' means no element." + default: "null" + + personality: + type: string + description: "Short personality descriptor used for relationship flavor and dialogue tags." + example: "stoic_protector" + + base_stats: + type: object + description: "Stats at level 1 / ascension 0. Scaling is applied by the server formula." + required: [hp, attack, defense, speed] + properties: + hp: + type: integer + minimum: 1 + attack: + type: integer + minimum: 1 + defense: + type: integer + minimum: 1 + speed: + type: integer + minimum: 1 + crit_rate: + type: number + minimum: 0.0 + maximum: 1.0 + default: 0.05 + crit_damage: + type: number + minimum: 1.0 + default: 1.5 + additionalProperties: false + + skills: + type: array + description: "Character skills. First entry is the combat passive; second is the signature active." + minItems: 1 + maxItems: 4 + items: + type: object + required: [id, name, description, type] + properties: + id: + type: string + pattern: "^[a-z][a-z0-9_]*$" + name: + type: string + description: + type: string + type: + type: string + enum: [passive, active, ultimate] + max_level: + type: integer + minimum: 1 + maximum: 10 + default: 5 + upgrade_cost_per_level: + type: object + description: "Resource costs to advance one skill level. Keys are resource IDs." + additionalProperties: + type: integer + minimum: 0 + + suitability_bonuses: + type: object + description: > + Multiplier applied to passive output when this character is assigned to a matching + job slot. A character with their primary profession gets 1.5x; secondary gets 1.2x; + unmatched gets 1.0x (no bonus). These values override the defaults. + properties: + primary_profession_multiplier: + type: number + minimum: 1.0 + default: 1.5 + secondary_professions: + type: array + items: + type: string + enum: [miner, lumberjack, researcher, construction, farmer, trainer] + description: "Additional professions where this character earns the secondary bonus." + additionalProperties: false + + bond_hooks: + type: array + description: "Relationship flavor tags used to unlock bond dialogue and minor stat bonuses." + items: + type: string + example: ["loyalty", "mentor", "rival"] + + lore_summary: + type: string + description: "One or two sentence background. Minimal story as per spec." diff --git a/content/schema/event.yaml b/content/schema/event.yaml new file mode 100644 index 0000000..cabf71b --- /dev/null +++ b/content/schema/event.yaml @@ -0,0 +1,91 @@ +# event.yaml — schema definition for live-ops event entries +# +# Events are content-defined, not code-defined. Adding a new event of an +# existing template type requires only a new YAML file, no code changes. +# +# PoC event templates (from spec): +# - resource_push: multiplied resource gain window +# - combat_score: score-attack combat ranking +# - profession_spotlight: boosted output for one profession + +$schema: "xyvera/content-schema/event/v1" + +type: object +required: + - id + - name + - template + - schedule + - rewards + +properties: + + id: + type: string + pattern: "^[a-z][a-z0-9_]*$" + + name: + type: string + + description: + type: string + + template: + type: string + enum: [resource_push, combat_score, profession_spotlight] + description: > + Determines which server-side event handler processes this event. + New templates require code; new instances of existing templates do not. + + schedule: + type: object + required: [start, end] + properties: + start: + type: string + format: date-time + end: + type: string + format: date-time + timezone_note: + type: string + description: "Optional human-readable timezone note for players." + + template_params: + type: object + description: > + Template-specific configuration. Contents depend on the template value. + resource_push: { resource_id, multiplier } + combat_score: { stage_id, score_formula } + profession_spotlight: { profession, bonus_multiplier } + additionalProperties: true + + rewards: + type: array + description: "Milestone reward tiers. Players claim each tier once." + items: + type: object + required: [milestone, reward] + properties: + milestone: + type: integer + minimum: 1 + description: "Cumulative event score or points required to unlock this reward tier." + reward: + type: object + description: "Map of resource_id to amount granted at this milestone." + additionalProperties: + type: integer + minimum: 1 + + banner_id: + type: string + description: "Optional linked banner that runs alongside this event." + + calendar_visible_from: + type: string + format: date-time + description: > + When this event appears on the player's calendar preview. + Must be earlier than schedule.start. Ensures low-FOMO scheduling + visibility per design spec. diff --git a/content/schema/job.yaml b/content/schema/job.yaml new file mode 100644 index 0000000..ea2d718 --- /dev/null +++ b/content/schema/job.yaml @@ -0,0 +1,99 @@ +# job.yaml — schema definition for village job/assignment slot definitions +# +# Jobs define what characters can be assigned to and what passive resources +# they generate. All tuning values should be content-driven so the economy +# can be rebalanced without code changes. +# +# PoC jobs (from spec): mining, woodcutting, research, construction, farming + +$schema: "xyvera/content-schema/job/v1" + +type: object +required: + - id + - name + - profession + - output_resource + - base_output_per_hour + - max_slots + +properties: + + id: + type: string + pattern: "^[a-z][a-z0-9_]*$" + example: "lumber_camp" + + name: + type: string + example: "Lumber Camp" + + description: + type: string + example: "Workers harvest wood from the forest edge. Essential for construction and upgrades." + + profession: + type: string + enum: [miner, lumberjack, researcher, construction, farmer, trainer] + description: "Determines which characters receive the suitability bonus when assigned here." + + output_resource: + type: string + description: "Resource ID produced passively by characters in this job slot." + example: "wood" + + secondary_output: + type: object + description: "Optional secondary resource produced at a lower rate." + properties: + resource: + type: string + rate_fraction: + type: number + minimum: 0.0 + maximum: 1.0 + description: "Fraction of base_output_per_hour for the secondary resource." + additionalProperties: false + + base_output_per_hour: + type: number + minimum: 0.0 + description: > + Base resource units generated per assigned character per hour. + Actual output = base * suitability_multiplier * character_level_factor. + Level factor formula: (level / max_level) ^ 0.5 — tunable in constants.yaml. + + max_slots: + type: integer + minimum: 1 + maximum: 10 + description: "Maximum number of characters that can be simultaneously assigned to this job." + + unlock_condition: + type: object + description: "What the player must have done before this job slot is available." + properties: + building_level: + type: integer + minimum: 0 + description: "Village building level required to unlock this slot." + prerequisite_job_id: + type: string + description: "Another job that must be unlocked first." + additionalProperties: false + + manual_work_eligible: + type: boolean + default: false + description: > + If true, this job can be targeted by the manual micro-management action. + Only one or two jobs should have this enabled in PoC to keep the system focused. + + passive_cap_hours: + type: number + minimum: 1.0 + default: 8.0 + description: > + Hours after which passive accumulation stops until the player collects. + Prevents long absences from breaking the economy. Default is 8 hours + matching the server-side MAX_PASSIVE_HOURS constant. diff --git a/content/schema/resource.yaml b/content/schema/resource.yaml new file mode 100644 index 0000000..43b056b --- /dev/null +++ b/content/schema/resource.yaml @@ -0,0 +1,96 @@ +# resource.yaml — schema definition for resource/currency type definitions +# +# Defines what each resource is, how it behaves, and display metadata. +# The economy starts with seven core resources as specified in the concept doc. + +$schema: "xyvera/content-schema/resource/v1" + +type: object +required: + - id + - name + - category + - description + +properties: + + id: + type: string + pattern: "^[a-z][a-z0-9_]*$" + description: "Matches the wallet key and all internal references." + example: "wood" + + name: + type: string + example: "Wood" + + description: + type: string + example: "Basic construction material harvested from forested areas." + + category: + type: string + enum: + - soft_currency # gold, wood, stone — general economy resources + - knowledge # research and skill upgrade material + - stamina # action budget (not in core 7, reserved for future) + - premium # diamond — sparse, monetisation-adjacent + - ascension # character ascension materials + - event # event-specific temporary currencies + - guild # guild and territory tokens + description: "Resource family used to group items in UI and apply economy rules." + + is_spendable: + type: boolean + default: true + description: "Can this resource be directly spent by the player? False for passive trackers." + + is_capped: + type: boolean + default: false + description: "Whether this resource has a maximum carry limit." + + carry_cap: + type: integer + minimum: 1 + description: "Maximum held quantity. Only meaningful when is_capped is true." + + decay_rate: + type: number + minimum: 0.0 + default: 0.0 + description: "Fraction lost per hour (0 = no decay). Reserved for future scarcity mechanics." + + icon: + type: string + description: "Path to the icon asset relative to godot/assets/." + example: "icons/resources/wood.png" + + color_hex: + type: string + pattern: "^#[0-9a-fA-F]{6}$" + description: "UI accent color for this resource." + example: "#8B5E3C" + + sources: + type: array + description: > + Informational list of where this resource comes from. + Used to power the inventory help tooltip so players always know + how to get more of it. (Economy Rule 4: inventory confusion is an economy bug.) + items: + type: string + example: + - "Lumber Camp passive output" + - "Manual wood-chopping action" + - "Event shop purchase" + + sinks: + type: array + description: "Informational list of what this resource is spent on." + items: + type: string + example: + - "Building upgrades" + - "Character ascension materials" + - "Construction job slot unlocks" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7e59ee5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,75 @@ +services: + # Nakama game backend + nakama: + image: heroiclabs/nakama:3.22.0 + container_name: xyvera_nakama + depends_on: + cockroachdb: + condition: service_healthy + entrypoint: + - "/bin/sh" + - "-ecx" + - > + /nakama/nakama migrate up --database.address root@cockroachdb:26257/nakama && + /nakama/nakama + --name xyvera + --database.address root@cockroachdb:26257/nakama + --socket.server_key "${NAKAMA_SERVER_KEY:-xyvera-dev-key}" + --session.token_expiry_sec 7200 + --runtime.path /nakama/data/modules + --logger.level DEBUG + volumes: + - ./nakama/modules:/nakama/data/modules + - nakama_data:/nakama/data + ports: + - "7349:7349" # client API + - "7350:7350" # gRPC API + - "7351:7351" # console UI + environment: + NAKAMA_SERVER_KEY: "${NAKAMA_SERVER_KEY:-xyvera-dev-key}" + restart: unless-stopped + + # CockroachDB — required by Nakama + cockroachdb: + image: cockroachdb/cockroach:v23.2.3 + container_name: xyvera_cockroachdb + command: start-single-node --insecure --store=attrs=ssd,path=/var/lib/cockroach/ + volumes: + - cockroach_data:/var/lib/cockroach + ports: + - "26257:26257" + - "8080:8080" # CockroachDB admin UI + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health?ready=1"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + restart: unless-stopped + + # PostgreSQL — game content, player state, and economy data + # Separate from Nakama's CockroachDB so schema migrations are clean and + # standalone from the Nakama-managed tables. + postgres: + image: postgres:16-alpine + container_name: xyvera_postgres + environment: + POSTGRES_DB: xyvera + POSTGRES_USER: xyvera + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-xyvera-dev-pass}" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./migrations:/docker-entrypoint-initdb.d:ro + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U xyvera -d xyvera"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + nakama_data: + cockroach_data: + postgres_data: diff --git a/godot/assets/placeholders/icon.png b/godot/assets/placeholders/icon.png new file mode 100644 index 0000000..bb98d44 Binary files /dev/null and b/godot/assets/placeholders/icon.png differ diff --git a/godot/project.godot b/godot/project.godot new file mode 100644 index 0000000..2050dfa --- /dev/null +++ b/godot/project.godot @@ -0,0 +1,45 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; but this file is committed so the project can be opened without further setup. + +config_version=5 + +[application] + +config/name="Xyvera" +config/description="Cozy management gacha with meaningful guild strategy." +config/version="0.0.1-poc" +run/main_scene="res://src/screens/MainMenu.tscn" +config/features=PackedStringArray("4.3", "Mobile") +config/icon="res://assets/placeholders/icon.png" + +[display] + +window/size/viewport_width=1080 +window/size/viewport_height=1920 +window/size/resizable=false +window/stretch/mode="canvas_items" +window/stretch/aspect="expand" + +[rendering] + +renderer/rendering_method="mobile" + +[autoload] + +NakamaClient="*res://src/nakama/NakamaClient.gd" +GameState="*res://src/systems/GameState.gd" + +[input] + +ui_accept={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194309,"physical_keycode":0,"key_label":0,"unicode":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":0,"key_label":0,"unicode":32,"echo":false,"script":null) +, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null) +] +} + +[filesystem] + +import/blender/enabled=false diff --git a/godot/src/nakama/NakamaClient.gd b/godot/src/nakama/NakamaClient.gd new file mode 100644 index 0000000..1998dac --- /dev/null +++ b/godot/src/nakama/NakamaClient.gd @@ -0,0 +1,113 @@ +## NakamaClient.gd — GDScript wrapper around the GDNakama plugin +## +## Registered as a global AutoLoad singleton (NakamaClient). +## All server calls go through here so the rest of the codebase never needs +## to know the Nakama session details or raw API. +## +## Setup required: +## Install the GDNakama plugin: https://github.com/heroiclabs/nakama-godot +## Enable it in Project > Project Settings > Plugins. +## +## Default connection target: localhost:7349 (matches docker-compose.yml) +## Override with the env var XYVERA_NAKAMA_HOST for other environments. + +extends Node + +# ── Configuration ───────────────────────────────────────────────────────────── + +const SERVER_HOST := "localhost" +const SERVER_PORT := 7349 +const SERVER_KEY := "xyvera-dev-key" +const SERVER_SCHEME := "http" + +# ── State ───────────────────────────────────────────────────────────────────── + +var _client: NakamaClient # GDNakama plugin instance (set in _ready) +var _session: NakamaSession # authenticated session +var _socket: NakamaSocket # real-time socket (used for future live features) + +signal session_changed(session: NakamaSession) +signal connected() +signal disconnected() + +# ── Lifecycle ───────────────────────────────────────────────────────────────── + +func _ready() -> void: + # GDNakama plugin creates a NakamaClient via the singleton factory. + # If the plugin is not installed this line will error at runtime. + _client = Nakama.create_client(SERVER_KEY, SERVER_HOST, SERVER_PORT, SERVER_SCHEME) + print("[NakamaClient] Configured: %s://%s:%d" % [SERVER_SCHEME, SERVER_HOST, SERVER_PORT]) + +# ── Auth ────────────────────────────────────────────────────────────────────── + +## Authenticate with a device ID (no account required in PoC). +## On first call, Nakama creates the account and triggers the seed hook. +func authenticate_device(device_id: String) -> NakamaSession: + var result: NakamaSession = await _client.authenticate_device_async(device_id) + if result.is_exception(): + push_error("[NakamaClient] Auth failed: %s" % result.get_exception().message) + return null + _session = result + session_changed.emit(_session) + print("[NakamaClient] Authenticated: %s" % _session.user_id) + return _session + +func is_authenticated() -> bool: + return _session != null and not _session.is_expired() + +# ── Generic RPC ─────────────────────────────────────────────────────────────── + +## Call a registered Nakama RPC and return the parsed JSON result. +## Returns null on error (check Godot Output for details). +func rpc(id: String, payload: Dictionary = {}) -> Variant: + if not is_authenticated(): + push_error("[NakamaClient] RPC called without active session: %s" % id) + return null + + var payload_str := JSON.stringify(payload) if not payload.is_empty() else "" + var result: NakamaAPI.ApiRpc = await _client.rpc_async(_session, id, payload_str) + + if result.is_exception(): + push_error("[NakamaClient] RPC error (%s): %s" % [id, result.get_exception().message]) + return null + + if result.payload.is_empty(): + return {} + + var json := JSON.new() + var err := json.parse(result.payload) + if err != OK: + push_error("[NakamaClient] Failed to parse RPC response for %s" % id) + return null + + return json.data + +# ── Convenience helpers ─────────────────────────────────────────────────────── + +func get_resources() -> Dictionary: + var result = await rpc("inventory/resources") + return result.get("resources", {}) if result else {} + +func get_village_state() -> Dictionary: + var result = await rpc("village/state") + return result if result else {} + +func collect_passive_resources() -> Dictionary: + var result = await rpc("village/collect") + return result if result else {} + +func manual_work(resource: String = "wood") -> Dictionary: + var result = await rpc("village/manual_work", {"resource": resource}) + return result if result else {} + +func get_banners() -> Array: + var result = await rpc("gacha/banners") + return result.get("banners", []) if result else [] + +func pull_gacha(banner_id: String, count: int = 1) -> Dictionary: + var result = await rpc("gacha/pull", {"banner_id": banner_id, "count": count}) + return result if result else {} + +func get_pity(banner_id: String) -> Dictionary: + var result = await rpc("gacha/pity", {"banner_id": banner_id}) + return result if result else {} diff --git a/godot/src/screens/GachaPull.gd b/godot/src/screens/GachaPull.gd new file mode 100644 index 0000000..8463e7a --- /dev/null +++ b/godot/src/screens/GachaPull.gd @@ -0,0 +1,79 @@ +## GachaPull.gd — Gacha pull / banner screen controller +## +## Shows available banners with rates, pity counter, and pull buttons. +## Implements the fair-visibility design pillar: +## - Pull rates displayed directly on the screen +## - Pity counter shown prominently +## - Banner end date and rerun policy visible +## +## Scene file: src/screens/GachaPull.tscn (create in Godot editor) + +extends Control + +var _current_banner_id: String = "standard" +var _banners: Array = [] + +@onready var banner_name_label: Label = $Header/BannerNameLabel +@onready var pity_label: Label = $Header/PityLabel +@onready var rates_label: Label = $Header/RatesLabel +@onready var rerun_label: Label = $Header/RerunLabel + +@onready var pull_1_button: Button = $Body/Pull1Button +@onready var pull_10_button: Button = $Body/Pull10Button +@onready var result_container: VBoxContainer = $Body/ResultContainer + +@onready var back_button: Button = $Footer/BackButton + +func _ready() -> void: + pull_1_button.pressed.connect(func(): _do_pull(1)) + pull_10_button.pressed.connect(func(): _do_pull(10)) + back_button.pressed.connect(func(): get_tree().change_scene_to_file("res://src/screens/Village.tscn")) + + _load_banners() + +func _load_banners() -> void: + _banners = await NakamaClient.get_banners() + if _banners.is_empty(): + banner_name_label.text = "Standard Banner (stub)" + pity_label.text = "Pity: 0 / 90" + rates_label.text = "SSR: 1.5% | SR: 8.5% | R: 90%\n(Soft pity from 70 pulls)" + rerun_label.text = "Permanent banner — always available" + return + + var b: Dictionary = _banners[0] + _current_banner_id = b.get("id", "standard") + banner_name_label.text = b.get("name", "Banner") + + var pity = await NakamaClient.get_pity(_current_banner_id) + pity_label.text = "Pity: %d / %d (soft pity at %d)" % [ + pity.get("current_pity", 0), + pity.get("hard_pity", 90), + pity.get("soft_pity_start", 70), + ] + +func _do_pull(count: int) -> void: + pull_1_button.disabled = true + pull_10_button.disabled = true + + for child in result_container.get_children(): + child.queue_free() + + var result = await NakamaClient.pull_gacha(_current_banner_id, count) + + if result and result.get("ok"): + var results_list: Array = result.get("results", []) + for r in results_list: + var lbl := Label.new() + lbl.text = "[%s] %s" % [r.get("rarity", "?"), r.get("character_id", "?")] + result_container.add_child(lbl) + # Refresh pity after pulling + var pity = await NakamaClient.get_pity(_current_banner_id) + pity_label.text = "Pity: %d / %d" % [pity.get("current_pity", 0), pity.get("hard_pity", 90)] + GameState.update_resources(await NakamaClient.get_resources()) + else: + var lbl := Label.new() + lbl.text = result.get("message", "Pull system not yet implemented (PR 5)") + result_container.add_child(lbl) + + pull_1_button.disabled = false + pull_10_button.disabled = false diff --git a/godot/src/screens/GachaPull.tscn b/godot/src/screens/GachaPull.tscn new file mode 100644 index 0000000..6d373d4 --- /dev/null +++ b/godot/src/screens/GachaPull.tscn @@ -0,0 +1,20 @@ +[gd_scene load_steps=2 format=3 uid=""] + +[ext_resource type="Script" path="res://src/screens/GachaPull.gd" id="1_placeholder"] + +[node name="GachaPull" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1_placeholder") + +[node name="Header" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 0 + +[node name="Body" type="VBoxContainer" parent="."] +layout_mode = 1 + +[node name="Footer" type="HBoxContainer" parent="."] +layout_mode = 1 diff --git a/godot/src/screens/Inventory.gd b/godot/src/screens/Inventory.gd new file mode 100644 index 0000000..39f52c0 --- /dev/null +++ b/godot/src/screens/Inventory.gd @@ -0,0 +1,82 @@ +## Inventory.gd — Character roster and item inventory screen controller +## +## Shows the player's character roster with filtering. +## Design spec: every item must show what it is for, where it came from, +## and where it can be spent. Inventory confusion is an economy bug. +## +## Scene file: src/screens/Inventory.tscn (create in Godot editor) + +extends Control + +var _roster: Array = [] +var _filter_rarity: String = "" +var _filter_profession: String = "" + +@onready var roster_container: VBoxContainer = $Body/RosterContainer +@onready var filter_rarity_option: OptionButton = $Header/Filters/RarityFilter +@onready var filter_profession_option: OptionButton = $Header/Filters/ProfessionFilter +@onready var back_button: Button = $Footer/BackButton + +func _ready() -> void: + back_button.pressed.connect(func(): get_tree().change_scene_to_file("res://src/screens/Village.tscn")) + + filter_rarity_option.item_selected.connect(_on_rarity_filter) + filter_profession_option.item_selected.connect(_on_profession_filter) + + _setup_filters() + _load_roster() + +func _setup_filters() -> void: + filter_rarity_option.add_item("All Rarities") + for r in ["R", "SR", "SSR", "UR"]: + filter_rarity_option.add_item(r) + + filter_profession_option.add_item("All Professions") + for p in ["miner", "lumberjack", "researcher", "construction", "farmer", "trainer"]: + filter_profession_option.add_item(p.capitalize()) + +func _load_roster() -> void: + var result = await NakamaClient.rpc("inventory/get") + if result and not result.get("error"): + _roster = result.get("characters", []) + else: + _roster = [] + + _render_roster() + +func _render_roster() -> void: + for child in roster_container.get_children(): + child.queue_free() + + var filtered := _roster.filter(func(c: Dictionary) -> bool: + if _filter_rarity and c.get("rarity") != _filter_rarity: + return false + if _filter_profession and c.get("profession") != _filter_profession.to_lower(): + return false + return true + ) + + if filtered.is_empty(): + var lbl := Label.new() + lbl.text = "No characters yet.\nPull from the banner to build your roster!" + roster_container.add_child(lbl) + return + + for c in filtered: + var lbl := Label.new() + lbl.text = "[%s] %s Lv.%d | %s | %s" % [ + c.get("rarity", "?"), + c.get("character_id", "?"), + c.get("level", 1), + c.get("combat_role", "?"), + c.get("profession", "?"), + ] + roster_container.add_child(lbl) + +func _on_rarity_filter(idx: int) -> void: + _filter_rarity = "" if idx == 0 else filter_rarity_option.get_item_text(idx) + _render_roster() + +func _on_profession_filter(idx: int) -> void: + _filter_profession = "" if idx == 0 else filter_profession_option.get_item_text(idx) + _render_roster() diff --git a/godot/src/screens/Inventory.tscn b/godot/src/screens/Inventory.tscn new file mode 100644 index 0000000..42b2703 --- /dev/null +++ b/godot/src/screens/Inventory.tscn @@ -0,0 +1,20 @@ +[gd_scene load_steps=2 format=3 uid=""] + +[ext_resource type="Script" path="res://src/screens/Inventory.gd" id="1_placeholder"] + +[node name="Inventory" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1_placeholder") + +[node name="Header" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 0 + +[node name="Body" type="VBoxContainer" parent="."] +layout_mode = 1 + +[node name="Footer" type="HBoxContainer" parent="."] +layout_mode = 1 diff --git a/godot/src/screens/MainMenu.gd b/godot/src/screens/MainMenu.gd new file mode 100644 index 0000000..7e80452 --- /dev/null +++ b/godot/src/screens/MainMenu.gd @@ -0,0 +1,45 @@ +## MainMenu.gd — Main menu screen controller +## +## The entry point scene. Handles device authentication and routes the player +## to the Village screen on success. +## Scene file: src/screens/MainMenu.tscn (create in Godot editor) + +extends Control + +@onready var status_label: Label = $VBoxContainer/StatusLabel +@onready var play_button: Button = $VBoxContainer/PlayButton + +func _ready() -> void: + play_button.pressed.connect(_on_play_pressed) + status_label.text = "Connecting..." + + # Attempt silent re-auth on launch using a persistent device ID + var device_id := _get_or_create_device_id() + var session = await NakamaClient.authenticate_device(device_id) + + if session: + status_label.text = "Welcome back, Traveller." + play_button.disabled = false + else: + status_label.text = "Could not connect to server.\nMake sure Docker is running." + play_button.disabled = true + +func _on_play_pressed() -> void: + await GameState.refresh_all() + get_tree().change_scene_to_file("res://src/screens/Village.tscn") + +func _get_or_create_device_id() -> String: + const SAVE_PATH := "user://device_id.txt" + if FileAccess.file_exists(SAVE_PATH): + var f := FileAccess.open(SAVE_PATH, FileAccess.READ) + var id := f.get_line().strip_edges() + f.close() + if not id.is_empty(): + return id + + # Generate a new random device ID and persist it + var new_id := "xyvera_" + str(randi()) + "_" + str(randi()) + var f := FileAccess.open(SAVE_PATH, FileAccess.WRITE) + f.store_line(new_id) + f.close() + return new_id diff --git a/godot/src/screens/MainMenu.tscn b/godot/src/screens/MainMenu.tscn new file mode 100644 index 0000000..af49ccb --- /dev/null +++ b/godot/src/screens/MainMenu.tscn @@ -0,0 +1,20 @@ +[gd_scene load_steps=2 format=3 uid=""] + +[ext_resource type="Script" path="res://src/screens/MainMenu.gd" id="1_placeholder"] + +[node name="MainMenu" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1_placeholder") + +[node name="Header" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 0 + +[node name="Body" type="VBoxContainer" parent="."] +layout_mode = 1 + +[node name="Footer" type="HBoxContainer" parent="."] +layout_mode = 1 diff --git a/godot/src/screens/Village.gd b/godot/src/screens/Village.gd new file mode 100644 index 0000000..c9b42e0 --- /dev/null +++ b/godot/src/screens/Village.gd @@ -0,0 +1,104 @@ +## Village.gd — Village / home screen controller +## +## The emotional hub of the game. Shows: +## - passive resource balances and collection button +## - assignment slot overview (characters assigned to jobs) +## - manual work button (the core PoC differentiator) +## - navigation links to Gacha and Inventory screens +## +## Scene file: src/screens/Village.tscn (create in Godot editor) + +extends Control + +@onready var gold_label: Label = $Header/Resources/GoldLabel +@onready var wood_label: Label = $Header/Resources/WoodLabel +@onready var stone_label: Label = $Header/Resources/StoneLabel +@onready var knowledge_label: Label = $Header/Resources/KnowledgeLabel +@onready var mana_label: Label = $Header/Resources/ManaLabel +@onready var diamond_label: Label = $Header/Resources/DiamondLabel + +@onready var collect_button: Button = $Body/CollectButton +@onready var manual_work_button: Button = $Body/ManualWorkButton +@onready var slots_container: VBoxContainer = $Body/SlotsContainer + +@onready var gacha_button: Button = $Footer/GachaButton +@onready var inventory_button: Button = $Footer/InventoryButton + +func _ready() -> void: + collect_button.pressed.connect(_on_collect_pressed) + manual_work_button.pressed.connect(_on_manual_work_pressed) + gacha_button.pressed.connect(_on_gacha_pressed) + inventory_button.pressed.connect(_on_inventory_pressed) + + GameState.resources_updated.connect(_refresh_resource_display) + GameState.village_updated.connect(_refresh_slots) + + _refresh_resource_display(GameState.resources) + _refresh_slots(GameState.village_slots) + +func _refresh_resource_display(res: Dictionary) -> void: + gold_label.text = "Gold: %d" % res.get("gold", 0) + wood_label.text = "Wood: %d" % res.get("wood", 0) + stone_label.text = "Stone: %d" % res.get("stone", 0) + knowledge_label.text = "Knowledge: %d" % res.get("knowledge", 0) + mana_label.text = "Mana: %d" % res.get("mana", 0) + diamond_label.text = "Diamonds: %d" % res.get("diamond", 0) + +func _refresh_slots(slots: Array) -> void: + for child in slots_container.get_children(): + child.queue_free() + + if slots.is_empty(): + var placeholder := Label.new() + placeholder.text = "No characters assigned yet.\n(Assignment system — PR 8)" + slots_container.add_child(placeholder) + return + + for slot in slots: + var label := Label.new() + label.text = "[%s] %s → %s" % [ + slot.get("job_id", "?"), + slot.get("character_id", "empty"), + slot.get("pending_yield", "?"), + ] + slots_container.add_child(label) + +func _on_collect_pressed() -> void: + collect_button.disabled = true + var result = await NakamaClient.collect_passive_resources() + if result and result.get("ok"): + var granted: Dictionary = result.get("granted", {}) + GameState.update_resources(await NakamaClient.get_resources()) + _show_toast("Collected: %s" % str(granted)) + else: + _show_toast(result.get("message", "Collection stubbed — implement PR 8")) + collect_button.disabled = false + +func _on_manual_work_pressed() -> void: + # Manual wood-chopping — the highest-priority PoC mechanic. + # This button should show a clear comparison: how much wood the burst + # action gave versus what passive would have yielded in the same time. + manual_work_button.disabled = true + var result = await NakamaClient.manual_work("wood") + + if result and result.get("ok"): + var burst: int = result.get("burst_yield", 0) + var passive: int = result.get("passive_equivalent", 0) + var used: int = result.get("daily_actions_used", 0) + var cap: int = result.get("daily_actions_cap", 3) + GameState.update_resources(await NakamaClient.get_resources()) + _show_toast("Manual wood +%d (passive would have given ~%d)\nActions today: %d/%d" % [burst, passive, used, cap]) + else: + _show_toast(result.get("message", "Manual work stubbed — implement in PR 5")) + + manual_work_button.disabled = false + +func _on_gacha_pressed() -> void: + get_tree().change_scene_to_file("res://src/screens/GachaPull.tscn") + +func _on_inventory_pressed() -> void: + get_tree().change_scene_to_file("res://src/screens/Inventory.tscn") + +func _show_toast(msg: String) -> void: + # TODO: replace with a proper toast/notification widget + print("[Village] %s" % msg) diff --git a/godot/src/screens/Village.tscn b/godot/src/screens/Village.tscn new file mode 100644 index 0000000..e72794c --- /dev/null +++ b/godot/src/screens/Village.tscn @@ -0,0 +1,20 @@ +[gd_scene load_steps=2 format=3 uid=""] + +[ext_resource type="Script" path="res://src/screens/Village.gd" id="1_placeholder"] + +[node name="Village" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource("1_placeholder") + +[node name="Header" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 0 + +[node name="Body" type="VBoxContainer" parent="."] +layout_mode = 1 + +[node name="Footer" type="HBoxContainer" parent="."] +layout_mode = 1 diff --git a/godot/src/systems/GameState.gd b/godot/src/systems/GameState.gd new file mode 100644 index 0000000..77323dd --- /dev/null +++ b/godot/src/systems/GameState.gd @@ -0,0 +1,56 @@ +## GameState.gd — Global game state cache +## +## AutoLoad singleton. Holds the last-known server state so screens can read +## data synchronously without issuing a new RPC on every frame. +## The server is always authoritative; this is a presentation cache only. + +extends Node + +# ── Player resources ─────────────────────────────────────────────────────────── + +var resources: Dictionary = { + "gold": 0, + "wood": 0, + "stone": 0, + "knowledge": 0, + "experience": 0, + "mana": 0, + "diamond": 0, +} + +signal resources_updated(new_resources: Dictionary) + +func update_resources(new_resources: Dictionary) -> void: + resources.merge(new_resources, true) + resources_updated.emit(resources) + +# ── Roster ──────────────────────────────────────────────────────────────────── + +var roster: Array = [] # Array of character state Dicts from the server + +signal roster_updated(new_roster: Array) + +func update_roster(new_roster: Array) -> void: + roster = new_roster + roster_updated.emit(roster) + +# ── Village ─────────────────────────────────────────────────────────────────── + +var village_slots: Array = [] # Assignment slot states + +signal village_updated(slots: Array) + +func update_village(new_slots: Array) -> void: + village_slots = new_slots + village_updated.emit(village_slots) + +# ── Session helpers ─────────────────────────────────────────────────────────── + +func refresh_all() -> void: + var res = await NakamaClient.get_resources() + if res: + update_resources(res) + + var village = await NakamaClient.get_village_state() + if village.get("slots"): + update_village(village["slots"]) diff --git a/infra/.env.example b/infra/.env.example new file mode 100644 index 0000000..d6b74f0 --- /dev/null +++ b/infra/.env.example @@ -0,0 +1,8 @@ +# Copy to .env and fill in values for local development. +# .env is gitignored — never commit real secrets. + +NAKAMA_SERVER_KEY=xyvera-dev-key +POSTGRES_PASSWORD=xyvera-dev-pass + +# Set to override the default localhost connection in the Godot client: +# XYVERA_NAKAMA_HOST=localhost diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql new file mode 100644 index 0000000..6147ada --- /dev/null +++ b/migrations/001_initial_schema.sql @@ -0,0 +1,185 @@ +-- 001_initial_schema.sql +-- Xyvera game database — initial schema +-- Applied automatically to the xyvera PostgreSQL container on first boot. +-- +-- This schema covers Steps 1–3 of the implementation sequence: +-- - player profiles and accounts +-- - resource inventory and currency ledger +-- - character roster +-- - gacha pull history and pity counters +-- +-- Nakama has its own tables in CockroachDB. This Postgres DB stores +-- game-specific state that Nakama does not model natively: +-- - economy ledger (full audit trail) +-- - character progression state +-- - village assignment history +-- - content configuration cache (loaded from YAML by the content importer) + +BEGIN; + +-- ── Extensions ────────────────────────────────────────────────────────────── + +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid() + +-- ── Players ────────────────────────────────────────────────────────────────── +-- Mirrors Nakama user IDs. All economy and progression data links here. + +CREATE TABLE players ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + nakama_user_id TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT 'Traveller', + region TEXT NOT NULL DEFAULT 'heartwood_vale', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_players_nakama_user_id ON players(nakama_user_id); + +-- ── Resource Ledger ─────────────────────────────────────────────────────────── +-- Every grant and spend recorded. Never update; only insert. +-- This makes the economy fully auditable (Economy Rule from spec). + +CREATE TABLE resource_ledger ( + id BIGSERIAL PRIMARY KEY, + player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, + resource_id TEXT NOT NULL, -- matches resource schema id + delta INTEGER NOT NULL, -- positive = grant, negative = spend + balance_after INTEGER NOT NULL, -- denormalized for easy dashboards + source TEXT NOT NULL, -- e.g. 'lumber_camp_collect', 'gacha_pull' + source_ref TEXT, -- optional FK to another table (e.g. pull_id) + notes JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_resource_ledger_player ON resource_ledger(player_id, resource_id); +CREATE INDEX idx_resource_ledger_source ON resource_ledger(source); +CREATE INDEX idx_resource_ledger_created ON resource_ledger(created_at); + +-- ── Character Roster ───────────────────────────────────────────────────────── + +CREATE TABLE roster ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, + character_id TEXT NOT NULL, -- matches character schema id + presentation TEXT NOT NULL DEFAULT 'female' CHECK (presentation IN ('female', 'male')), + level INTEGER NOT NULL DEFAULT 1 CHECK (level >= 1 AND level <= 100), + ascension_tier INTEGER NOT NULL DEFAULT 0 CHECK (ascension_tier >= 0 AND ascension_tier <= 6), + bond_level INTEGER NOT NULL DEFAULT 0, + assignment_mastery JSONB NOT NULL DEFAULT '{}', -- {job_id: hours_accumulated} + skills JSONB NOT NULL DEFAULT '{}', -- {skill_id: current_level} + obtained_at TIMESTAMPTZ NOT NULL DEFAULT now(), + obtained_source TEXT NOT NULL, -- 'gacha_standard', 'gacha_event', 'starter_seed' + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX idx_roster_player_char ON roster(player_id, character_id); +CREATE INDEX idx_roster_player ON roster(player_id); + +-- ── Village Assignment State ────────────────────────────────────────────────── + +CREATE TABLE village_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, + roster_id UUID NOT NULL REFERENCES roster(id) ON DELETE CASCADE, + job_id TEXT NOT NULL, -- matches job schema id + assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE (player_id, roster_id) -- one assignment per character +); + +CREATE INDEX idx_village_assignments_player ON village_assignments(player_id); + +-- ── Manual Work State ──────────────────────────────────────────────────────── +-- Tracks daily manual work action counts for diminishing-returns enforcement. + +CREATE TABLE manual_work_state ( + player_id UUID PRIMARY KEY REFERENCES players(id) ON DELETE CASCADE, + actions_today INTEGER NOT NULL DEFAULT 0, + actions_reset_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- midnight UTC of current day + total_actions_lifetime BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ── Gacha Pull History ──────────────────────────────────────────────────────── + +CREATE TABLE pulls ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, + banner_id TEXT NOT NULL, + character_id TEXT NOT NULL, + rarity TEXT NOT NULL CHECK (rarity IN ('R', 'SR', 'SSR', 'UR')), + pity_count_at_pull INTEGER NOT NULL, + was_soft_pity BOOLEAN NOT NULL DEFAULT false, + was_hard_pity BOOLEAN NOT NULL DEFAULT false, + was_featured BOOLEAN NOT NULL DEFAULT false, + pulled_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_pulls_player ON pulls(player_id, pulled_at DESC); +CREATE INDEX idx_pulls_banner ON pulls(banner_id, pulled_at DESC); + +-- ── Pity Counters ───────────────────────────────────────────────────────────── +-- One row per (player, banner). Updated after every pull. + +CREATE TABLE pity_counters ( + player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE, + banner_id TEXT NOT NULL, + current_pity INTEGER NOT NULL DEFAULT 0, + featured_guarantee_due BOOLEAN NOT NULL DEFAULT false, -- lost the 50/50; next SSR guaranteed + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + PRIMARY KEY (player_id, banner_id) +); + +-- ── Content Cache Tables ────────────────────────────────────────────────────── +-- These are populated by tools/import_content.py from the YAML content files. +-- They are NOT the source of truth (YAML files are); they are the runtime cache. + +CREATE TABLE content_characters ( + id TEXT PRIMARY KEY, + data JSONB NOT NULL, + schema_version TEXT NOT NULL DEFAULT 'v1', + imported_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE content_jobs ( + id TEXT PRIMARY KEY, + data JSONB NOT NULL, + schema_version TEXT NOT NULL DEFAULT 'v1', + imported_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE content_resources ( + id TEXT PRIMARY KEY, + data JSONB NOT NULL, + schema_version TEXT NOT NULL DEFAULT 'v1', + imported_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE content_banners ( + id TEXT PRIMARY KEY, + data JSONB NOT NULL, + active BOOLEAN NOT NULL DEFAULT false, + schema_version TEXT NOT NULL DEFAULT 'v1', + imported_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE content_events ( + id TEXT PRIMARY KEY, + data JSONB NOT NULL, + active BOOLEAN NOT NULL DEFAULT false, + schema_version TEXT NOT NULL DEFAULT 'v1', + imported_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ── Schema Version Tracking ─────────────────────────────────────────────────── + +CREATE TABLE schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO schema_migrations(version) VALUES ('001_initial_schema'); + +COMMIT; diff --git a/nakama/modules/auth.ts b/nakama/modules/auth.ts new file mode 100644 index 0000000..ad7f5d3 --- /dev/null +++ b/nakama/modules/auth.ts @@ -0,0 +1,56 @@ +// auth.ts — account lifecycle hooks +// +// Seeds a new player account with starter resources and one guaranteed +// starter character pull. Everything here must be idempotent: if called +// more than once on the same account (e.g. after a reconnect), it must +// not duplicate grants. + +const STARTER_RESOURCES: Record = { + gold: 500, + wood: 200, + stone: 100, + knowledge: 50, + experience: 0, + mana: 30, + diamond: 100, // starter diamonds — enough to feel the pull system +}; + +const STORAGE_KEY_SEEDED = "account_seeded"; + +export function afterAuthenticateDevice( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + out: nkruntime.Session, + _in: nkruntime.AuthenticateDeviceRequest +): void { + const userId = ctx.userId; + + // Guard: only seed once + const existing = nk.storageRead([ + { collection: "player", key: STORAGE_KEY_SEEDED, userId }, + ]); + if (existing.length > 0) return; + + logger.info(`Seeding new account: ${userId}`); + + // Grant starter wallet balances + const walletChangeset: { [key: string]: number } = STARTER_RESOURCES; + nk.walletUpdate(userId, walletChangeset, { source: "new_account_seed" }); + + // Mark account as seeded so this block never runs again + nk.storageWrite([ + { + collection: "player", + key: STORAGE_KEY_SEEDED, + userId, + value: { seeded_at: Date.now() }, + permissionRead: 1, + permissionWrite: 0, + }, + ]); + + // TODO PR 3: write player profile object (display name, server, region) + // TODO PR 4: create ledger entry for seed grant + // TODO PR 5: trigger one free starter banner pull +} diff --git a/nakama/modules/gacha.ts b/nakama/modules/gacha.ts new file mode 100644 index 0000000..b0bb362 --- /dev/null +++ b/nakama/modules/gacha.ts @@ -0,0 +1,112 @@ +// gacha.ts — authoritative pull execution +// +// All pull outcomes are resolved server-side. The client sends a banner ID and +// pull count; this module resolves rarity, determines the result from the +// banner pool, updates pity counters, deducts currency, and records pull +// history. Nothing here is computed client-side. +// +// Pity model (PoC defaults — all values should be content-driven): +// - Soft pity begins at 70 pulls (increasing rate toward hard pity) +// - Hard pity at 90 pulls guarantees SSR +// - Featured SSR rate-up: 50% chance when SSR hits +// - Pity carries over between sessions but NOT between different banners +// unless the banner explicitly declares carry-over. + +export interface BannerDefinition { + id: string; + name: string; + type: "standard" | "featured" | "event"; + pull_cost_currency: string; + pull_cost_amount: number; + multi_cost_amount: number; // cost for 10-pull; usually 10x single minus one + pool: BannerPoolEntry[]; + soft_pity_start: number; + hard_pity: number; + featured_character_ids: string[]; + active_from: number; // unix ms + active_until: number | null; // null = permanent +} + +export interface BannerPoolEntry { + character_id: string; + rarity: "R" | "SR" | "SSR" | "UR"; + base_weight: number; +} + +export interface PullRecord { + banner_id: string; + character_id: string; + rarity: string; + pulled_at: number; + pity_count_at_pull: number; + was_soft_pity: boolean; + was_hard_pity: boolean; +} + +// ── RPCs ────────────────────────────────────────────────────────────────── + +export function rpcGachaPull( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + // TODO PR 5: implement full pull resolution + // Stub returns a placeholder response so the endpoint is registered and + // callable from the Godot client in PoC step 1. + logger.info(`gacha/pull stub called by ${ctx.userId}`); + + const req = JSON.parse(payload || "{}"); + const bannerId: string = req.banner_id ?? "standard"; + const count: number = req.count ?? 1; + + if (count !== 1 && count !== 10) { + throw new Error("Pull count must be 1 or 10"); + } + + return JSON.stringify({ + ok: false, + error: "not_implemented", + message: "Gacha pull system is stubbed. Implement in PR 5.", + banner_id: bannerId, + count, + }); +} + +export function rpcListBanners( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + _payload: string +): string { + // TODO PR 5: load active banners from content storage + logger.info(`gacha/banners stub called by ${ctx.userId}`); + return JSON.stringify({ banners: [], message: "Banner list is stubbed. Implement in PR 5." }); +} + +export function rpcGetPity( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + // TODO PR 5: read pity counters from player storage + const req = JSON.parse(payload || "{}"); + return JSON.stringify({ + banner_id: req.banner_id ?? "standard", + current_pity: 0, + soft_pity_start: 70, + hard_pity: 90, + message: "Pity counter is stubbed. Implement in PR 5.", + }); +} + +export function rpcGetPullHistory( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + _payload: string +): string { + // TODO PR 5: load pull history from player storage + return JSON.stringify({ history: [], message: "Pull history is stubbed. Implement in PR 5." }); +} diff --git a/nakama/modules/inventory.ts b/nakama/modules/inventory.ts new file mode 100644 index 0000000..04ba70b --- /dev/null +++ b/nakama/modules/inventory.ts @@ -0,0 +1,52 @@ +// inventory.ts — player inventory and resource ledger +// +// Every grant and spend must produce a ledger entry so the economy is +// fully auditable. The wallet tracks numeric resources (gold, wood, etc.). +// The inventory tracks discrete items and character roster membership. + +// Resource IDs — must match the keys used in content/schema/resource.yaml +export const RESOURCE_IDS = [ + "gold", + "wood", + "stone", + "knowledge", + "experience", + "mana", + "diamond", +] as const; + +export type ResourceId = typeof RESOURCE_IDS[number]; + +export function rpcGetInventory( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + _payload: string +): string { + // TODO PR 4: read character roster and item inventory from storage + logger.info(`inventory/get stub called by ${ctx.userId}`); + + return JSON.stringify({ + characters: [], + items: [], + message: "Inventory is stubbed. Implement in PR 4.", + }); +} + +export function rpcGetResources( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + _payload: string +): string { + // Read wallet balances — these are already tracked by Nakama's wallet system + const account = nk.accountGetId(ctx.userId); + const wallet = account.wallet ?? {}; + + const resources: Record = {}; + for (const id of RESOURCE_IDS) { + resources[id] = Number(wallet[id] ?? 0); + } + + return JSON.stringify({ resources }); +} diff --git a/nakama/modules/main.ts b/nakama/modules/main.ts new file mode 100644 index 0000000..4faea2b --- /dev/null +++ b/nakama/modules/main.ts @@ -0,0 +1,52 @@ +// Xyvera — Nakama server-side TypeScript runtime entry point +// All game logic that touches currency, pulls, or inventory must execute +// server-side only. The client is never trusted for economy-critical decisions. + +import * as auth from "./auth"; +import * as gacha from "./gacha"; +import * as inventory from "./inventory"; +import * as progression from "./progression"; +import * as village from "./village"; + +// InitModule is called once by Nakama at startup. +// Register all RPCs and hooks here. +function InitModule( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + initializer: nkruntime.Initializer +): Error | void { + logger.info("Xyvera module initialising"); + + // ── Auth ────────────────────────────────────────────────────────────────── + // Called after a new account is created. Seeds the player's starting state. + initializer.registerAfterAuthenticateDevice(auth.afterAuthenticateDevice); + + // ── Gacha ───────────────────────────────────────────────────────────────── + // Authoritative pull execution. Client never resolves pull outcomes. + initializer.registerRpc("gacha/pull", gacha.rpcGachaPull); + initializer.registerRpc("gacha/banners", gacha.rpcListBanners); + initializer.registerRpc("gacha/pity", gacha.rpcGetPity); + initializer.registerRpc("gacha/history", gacha.rpcGetPullHistory); + + // ── Inventory ───────────────────────────────────────────────────────────── + initializer.registerRpc("inventory/get", inventory.rpcGetInventory); + initializer.registerRpc("inventory/resources", inventory.rpcGetResources); + + // ── Progression ─────────────────────────────────────────────────────────── + initializer.registerRpc("progression/level_up", progression.rpcLevelUp); + initializer.registerRpc("progression/ascend", progression.rpcAscend); + initializer.registerRpc("progression/upgrade_skill", progression.rpcUpgradeSkill); + + // ── Village and assignment ───────────────────────────────────────────────── + // Passive resource accumulation is calculated server-side at claim time. + initializer.registerRpc("village/state", village.rpcGetVillageState); + initializer.registerRpc("village/assign", village.rpcAssignCharacter); + initializer.registerRpc("village/unassign", village.rpcUnassignCharacter); + initializer.registerRpc("village/collect", village.rpcCollectPassiveResources); + // Manual micro-management endpoint — the core differentiating mechanic. + // Server enforces diminishing returns and daily caps. + initializer.registerRpc("village/manual_work", village.rpcManualWork); + + logger.info("Xyvera module initialised successfully"); +} diff --git a/nakama/modules/progression.ts b/nakama/modules/progression.ts new file mode 100644 index 0000000..d39ef33 --- /dev/null +++ b/nakama/modules/progression.ts @@ -0,0 +1,68 @@ +// progression.ts — character level, ascension, and skill upgrade +// +// All upgrade cost validation is server-side. Costs come from content data +// (content/data/characters/), not hardcoded constants, so they can be tuned +// without code changes. +// +// Progression layers (per spec): +// 1. Character level (capped per ascension tier) +// 2. Ascension / limit break (unlocks higher level cap, costs materials) +// 3. Skill upgrades (costs knowledge + gold) +// 4. Equipment (deferred beyond PoC) +// 5. Bond / familiarity (tracked; mechanical outputs deferred) +// 6. Assignment mastery (tracked in village module) + +export function rpcLevelUp( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + // TODO PR 6: validate level-up cost, deduct resources, write new level + const req = JSON.parse(payload || "{}"); + logger.info(`progression/level_up stub: char=${req.character_id} user=${ctx.userId}`); + + return JSON.stringify({ + ok: false, + error: "not_implemented", + message: "Level-up is stubbed. Implement in PR 6.", + character_id: req.character_id, + }); +} + +export function rpcAscend( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + // TODO PR 6: validate ascension materials, raise tier, uncap level + const req = JSON.parse(payload || "{}"); + logger.info(`progression/ascend stub: char=${req.character_id} user=${ctx.userId}`); + + return JSON.stringify({ + ok: false, + error: "not_implemented", + message: "Ascension is stubbed. Implement in PR 6.", + character_id: req.character_id, + }); +} + +export function rpcUpgradeSkill( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + // TODO PR 6: validate skill upgrade cost, apply upgrade + const req = JSON.parse(payload || "{}"); + logger.info(`progression/upgrade_skill stub: char=${req.character_id} skill=${req.skill_id} user=${ctx.userId}`); + + return JSON.stringify({ + ok: false, + error: "not_implemented", + message: "Skill upgrade is stubbed. Implement in PR 6.", + character_id: req.character_id, + skill_id: req.skill_id, + }); +} diff --git a/nakama/modules/village.ts b/nakama/modules/village.ts new file mode 100644 index 0000000..f831552 --- /dev/null +++ b/nakama/modules/village.ts @@ -0,0 +1,131 @@ +// village.ts — village state, assignment loop, and manual micro-management +// +// This is the highest-priority system to validate in the PoC. The core design +// question: does the manual work loop feel meaningfully better than passive +// waiting, without becoming mandatory misery? +// +// Passive resource generation rules: +// - Each job slot generates resources while characters are assigned. +// - Output is calculated at collection time (lazy evaluation), not on a tick. +// - Passive output caps after MAX_PASSIVE_HOURS hours to discourage long +// check-in skips from breaking the economy. +// - A character's profession suitability bonus multiplies their slot output. +// +// Manual micro-management rules: +// - The player can spend MANUAL_WORK_COST_MANA to trigger a burst gain on +// one scarce resource (default: wood — defined as the first bottleneck). +// - Burst gain is 3–5x the passive rate per action but subject to a daily cap. +// - Each successive manual action within the same day gives diminishing returns +// (50% yield per extra action after the third). +// - Server tracks manual_work_count_today and manual_work_reset_at per player. +// +// These constants are PoC defaults. Production values must come from content. + +const MAX_PASSIVE_HOURS = 8; // cap passive accumulation at 8 hours +const MANUAL_WORK_COST_MANA = 5; +const MANUAL_WORK_DAILY_FULL_CAP = 3; // first 3 actions give full burst yield +const MANUAL_WORK_BURST_MULTIPLIER = 4; // 4x passive rate per burst action +const MANUAL_WORK_DIMINISHING_FACTOR = 0.5; // 50% per extra action after cap + +export function rpcGetVillageState( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + _payload: string +): string { + // TODO PR 8: read village state, assignment slots, and pending passive yield + logger.info(`village/state stub called by ${ctx.userId}`); + + return JSON.stringify({ + slots: [], + pending_resources: {}, + message: "Village state is stubbed. Implement in PR 8.", + }); +} + +export function rpcAssignCharacter( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + // TODO PR 8: validate character ownership, job compatibility, write assignment + const req = JSON.parse(payload || "{}"); + logger.info(`village/assign stub: char=${req.character_id} job=${req.job_id} user=${ctx.userId}`); + + return JSON.stringify({ + ok: false, + error: "not_implemented", + message: "Assignment is stubbed. Implement in PR 8.", + }); +} + +export function rpcUnassignCharacter( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + const req = JSON.parse(payload || "{}"); + logger.info(`village/unassign stub: char=${req.character_id} user=${ctx.userId}`); + + return JSON.stringify({ + ok: false, + error: "not_implemented", + message: "Unassignment is stubbed. Implement in PR 8.", + }); +} + +export function rpcCollectPassiveResources( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + _payload: string +): string { + // TODO PR 8: + // 1. Read assignment state and last_collected_at timestamp. + // 2. Calculate elapsed time (capped at MAX_PASSIVE_HOURS). + // 3. Sum per-slot output adjusted by suitability bonuses. + // 4. Grant resources via wallet update with ledger entry. + // 5. Write new last_collected_at. + logger.info(`village/collect stub called by ${ctx.userId}`); + + return JSON.stringify({ + ok: false, + granted: {}, + error: "not_implemented", + message: "Passive collection is stubbed. Implement in PR 8.", + }); +} + +export function rpcManualWork( + ctx: nkruntime.Context, + logger: nkruntime.Logger, + nk: nkruntime.Nakama, + payload: string +): string { + // TODO PR 5 (priority — this is the core PoC differentiator): + // 1. Read player manual_work_count_today; reset if a new day. + // 2. Check mana balance >= MANUAL_WORK_COST_MANA. + // 3. Deduct mana. + // 4. Calculate burst yield: + // - If count < MANUAL_WORK_DAILY_FULL_CAP: full burst + // - Else: apply MANUAL_WORK_DIMINISHING_FACTOR per extra action + // 5. Grant scarce resource (wood by default in PoC). + // 6. Increment manual_work_count_today. + // 7. Return new balance and yield so the client can show the comparison. + const req = JSON.parse(payload || "{}"); + const targetResource: string = req.resource ?? "wood"; + logger.info(`village/manual_work stub: resource=${targetResource} user=${ctx.userId}`); + + return JSON.stringify({ + ok: false, + error: "not_implemented", + resource: targetResource, + burst_yield: 0, + passive_equivalent: 0, + daily_actions_used: 0, + daily_actions_cap: MANUAL_WORK_DAILY_FULL_CAP, + message: "Manual work is stubbed. This is the highest-priority PoC feature — implement in PR 5.", + }); +} diff --git a/nakama/package.json b/nakama/package.json new file mode 100644 index 0000000..9934ff6 --- /dev/null +++ b/nakama/package.json @@ -0,0 +1,15 @@ +{ + "name": "xyvera-nakama", + "version": "0.0.1", + "description": "Xyvera Nakama server-side TypeScript modules", + "private": true, + "scripts": { + "build": "npx tsc", + "watch": "npx tsc --watch", + "typecheck": "npx tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.4.0", + "@heroiclabs/nakama-runtime": "^3.22.0" + } +} diff --git a/nakama/tsconfig.json b/nakama/tsconfig.json new file mode 100644 index 0000000..0371305 --- /dev/null +++ b/nakama/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["es6"], + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "outDir": "./modules", + "rootDir": "./modules", + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["modules/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/tools/validate_content.py b/tools/validate_content.py new file mode 100644 index 0000000..deac1f1 --- /dev/null +++ b/tools/validate_content.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""validate_content.py — Validate all content YAML files against their schemas. + +Usage: + python3 tools/validate_content.py + +Requires: + pip install pyyaml jsonschema + +Exit codes: + 0 — all files valid + 1 — one or more validation errors +""" + +import sys +import pathlib +import yaml +import json + +try: + import jsonschema +except ImportError: + print("ERROR: jsonschema not installed. Run: pip install pyyaml jsonschema") + sys.exit(1) + +CONTENT_ROOT = pathlib.Path(__file__).parent.parent / "content" +SCHEMA_DIR = CONTENT_ROOT / "schema" +DATA_DIR = CONTENT_ROOT / "data" + +# Map schema file names to data subdirectories +SCHEMA_DATA_MAP = { + "character.yaml": DATA_DIR / "characters", + "job.yaml": DATA_DIR / "jobs", + "resource.yaml": DATA_DIR / "resources", + "banner.yaml": DATA_DIR / "banners", + "event.yaml": DATA_DIR / "events", +} + +errors_found = 0 + + +def load_yaml(path: pathlib.Path) -> list[dict]: + """Load a YAML file, returning a list of documents (multi-doc files are split).""" + with open(path) as f: + docs = list(yaml.safe_load_all(f)) + return [d for d in docs if d is not None] + + +def yaml_schema_to_jsonschema(schema_doc: dict) -> dict: + """ + Our schema files are JSON-Schema-like YAML. Extract the JSON Schema portion. + We strip the custom '$schema' key and pass the rest to jsonschema. + """ + s = dict(schema_doc) + s.pop("$schema", None) + return s + + +def validate_directory(schema_path: pathlib.Path, data_dir: pathlib.Path) -> int: + """Validate all YAML files in data_dir against schema_path. Returns error count.""" + global errors_found + + if not data_dir.exists(): + print(f" [SKIP] Directory not found: {data_dir}") + return 0 + + schema_docs = load_yaml(schema_path) + if not schema_docs: + print(f" [WARN] Empty schema: {schema_path.name}") + return 0 + + schema = yaml_schema_to_jsonschema(schema_docs[0]) + + local_errors = 0 + yaml_files = sorted(data_dir.glob("*.yaml")) + + if not yaml_files: + print(f" [INFO] No data files in {data_dir.name}/") + return 0 + + for data_file in yaml_files: + docs = load_yaml(data_file) + for i, doc in enumerate(docs): + try: + # jsonschema uses JSON Schema draft-07 by default + jsonschema.validate(instance=doc, schema=schema) + except jsonschema.ValidationError as e: + print(f" [FAIL] {data_file.name} (doc {i+1}): {e.message}") + local_errors += 1 + except jsonschema.SchemaError as e: + print(f" [SCHEMA ERROR] {schema_path.name}: {e.message}") + local_errors += 1 + break + + if local_errors == 0: + pass # silent success; only print failures + + if local_errors == 0: + print(f" [OK] {data_dir.name}/ — {len(yaml_files)} file(s) valid") + + return local_errors + + +def main() -> None: + print("Xyvera content validation\n") + + for schema_name, data_dir in SCHEMA_DATA_MAP.items(): + schema_path = SCHEMA_DIR / schema_name + if not schema_path.exists(): + print(f" [WARN] Schema not found: {schema_name}") + continue + + print(f"Schema: {schema_name}") + errs = validate_directory(schema_path, data_dir) + errors_found += errs + + print() + if errors_found: + print(f"FAILED — {errors_found} validation error(s) found.") + sys.exit(1) + else: + print("All content valid.") + sys.exit(0) + + +if __name__ == "__main__": + main()