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 <noreply@anthropic.com>
This commit is contained in:
commit
fb00954954
37 changed files with 2730 additions and 0 deletions
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
182
README.md
Normal file
182
README.md
Normal file
|
|
@ -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.
|
||||
80
content/data/characters/ember_ashvale.yaml
Normal file
80
content/data/characters/ember_ashvale.yaml
Normal file
|
|
@ -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.
|
||||
77
content/data/characters/lyra_stoneheart.yaml
Normal file
77
content/data/characters/lyra_stoneheart.yaml
Normal file
|
|
@ -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.
|
||||
79
content/data/characters/sable_quillworth.yaml
Normal file
79
content/data/characters/sable_quillworth.yaml
Normal file
|
|
@ -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.
|
||||
33
content/data/jobs/lumber_camp.yaml
Normal file
33
content/data/jobs/lumber_camp.yaml
Normal file
|
|
@ -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
|
||||
32
content/data/jobs/research_post.yaml
Normal file
32
content/data/jobs/research_post.yaml
Normal file
|
|
@ -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
|
||||
149
content/data/resources/core_resources.yaml
Normal file
149
content/data/resources/core_resources.yaml
Normal file
|
|
@ -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)"
|
||||
121
content/schema/banner.yaml
Normal file
121
content/schema/banner.yaml
Normal file
|
|
@ -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."
|
||||
165
content/schema/character.yaml
Normal file
165
content/schema/character.yaml
Normal file
|
|
@ -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."
|
||||
91
content/schema/event.yaml
Normal file
91
content/schema/event.yaml
Normal file
|
|
@ -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.
|
||||
99
content/schema/job.yaml
Normal file
99
content/schema/job.yaml
Normal file
|
|
@ -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.
|
||||
96
content/schema/resource.yaml
Normal file
96
content/schema/resource.yaml
Normal file
|
|
@ -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"
|
||||
75
docker-compose.yml
Normal file
75
docker-compose.yml
Normal file
|
|
@ -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:
|
||||
BIN
godot/assets/placeholders/icon.png
Normal file
BIN
godot/assets/placeholders/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 179 B |
45
godot/project.godot
Normal file
45
godot/project.godot
Normal file
|
|
@ -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
|
||||
113
godot/src/nakama/NakamaClient.gd
Normal file
113
godot/src/nakama/NakamaClient.gd
Normal file
|
|
@ -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 {}
|
||||
79
godot/src/screens/GachaPull.gd
Normal file
79
godot/src/screens/GachaPull.gd
Normal file
|
|
@ -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
|
||||
20
godot/src/screens/GachaPull.tscn
Normal file
20
godot/src/screens/GachaPull.tscn
Normal file
|
|
@ -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
|
||||
82
godot/src/screens/Inventory.gd
Normal file
82
godot/src/screens/Inventory.gd
Normal file
|
|
@ -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()
|
||||
20
godot/src/screens/Inventory.tscn
Normal file
20
godot/src/screens/Inventory.tscn
Normal file
|
|
@ -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
|
||||
45
godot/src/screens/MainMenu.gd
Normal file
45
godot/src/screens/MainMenu.gd
Normal file
|
|
@ -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
|
||||
20
godot/src/screens/MainMenu.tscn
Normal file
20
godot/src/screens/MainMenu.tscn
Normal file
|
|
@ -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
|
||||
104
godot/src/screens/Village.gd
Normal file
104
godot/src/screens/Village.gd
Normal file
|
|
@ -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)
|
||||
20
godot/src/screens/Village.tscn
Normal file
20
godot/src/screens/Village.tscn
Normal file
|
|
@ -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
|
||||
56
godot/src/systems/GameState.gd
Normal file
56
godot/src/systems/GameState.gd
Normal file
|
|
@ -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"])
|
||||
8
infra/.env.example
Normal file
8
infra/.env.example
Normal file
|
|
@ -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
|
||||
185
migrations/001_initial_schema.sql
Normal file
185
migrations/001_initial_schema.sql
Normal file
|
|
@ -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;
|
||||
56
nakama/modules/auth.ts
Normal file
56
nakama/modules/auth.ts
Normal file
|
|
@ -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<string, number> = {
|
||||
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
|
||||
}
|
||||
112
nakama/modules/gacha.ts
Normal file
112
nakama/modules/gacha.ts
Normal file
|
|
@ -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." });
|
||||
}
|
||||
52
nakama/modules/inventory.ts
Normal file
52
nakama/modules/inventory.ts
Normal file
|
|
@ -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<string, number> = {};
|
||||
for (const id of RESOURCE_IDS) {
|
||||
resources[id] = Number(wallet[id] ?? 0);
|
||||
}
|
||||
|
||||
return JSON.stringify({ resources });
|
||||
}
|
||||
52
nakama/modules/main.ts
Normal file
52
nakama/modules/main.ts
Normal file
|
|
@ -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");
|
||||
}
|
||||
68
nakama/modules/progression.ts
Normal file
68
nakama/modules/progression.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
131
nakama/modules/village.ts
Normal file
131
nakama/modules/village.ts
Normal file
|
|
@ -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.",
|
||||
});
|
||||
}
|
||||
15
nakama/package.json
Normal file
15
nakama/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
16
nakama/tsconfig.json
Normal file
16
nakama/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
127
tools/validate_content.py
Normal file
127
tools/validate_content.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue