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
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.",
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue