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