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:
caoimhinr 2026-06-02 23:33:06 +02:00
commit fb00954954
37 changed files with 2730 additions and 0 deletions

25
.gitignore vendored Normal file
View 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
View file

@ -0,0 +1,182 @@
# Xyvera
Cozy management gacha with meaningful guild strategy.
This repository is the proof-of-concept scaffold. It covers Steps 12 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.

View 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.

View 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.

View 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.

View 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

View 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

View 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
View 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."

View 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
View 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
View 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.

View 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
View 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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

45
godot/project.godot Normal file
View 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

View 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 {}

View 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

View 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

View 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()

View 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

View 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

View 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

View 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)

View 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

View 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
View 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

View 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 13 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
View 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
View 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." });
}

View 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
View 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");
}

View 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
View 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 35x 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
View 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
View 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
View 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()