init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
import {
type SavedGameSnapshot as SharedSavedGameSnapshot,
type SavedGameSnapshotInput as SharedSavedGameSnapshotInput,
} from '../../packages/shared/src/contracts/runtime';
import type {GameState, StoryMoment} from '../types';
import type {BottomTab} from '../types/navigation';
export type SavedGameSnapshot = SharedSavedGameSnapshot<
GameState,
BottomTab,
StoryMoment
>;
export type SavedGameSnapshotInput = SharedSavedGameSnapshotInput<
GameState,
BottomTab,
StoryMoment
>;

View File

@@ -0,0 +1,83 @@
import {afterEach, describe, expect, it, vi} from 'vitest';
import {
clampVolume,
DEFAULT_MUSIC_VOLUME,
normalizePlatformTheme,
readSavedSettings,
writeSavedSettings,
} from './gameSettingsStorage';
import type {JsonStorage} from './storage';
function createMemoryStorage(): JsonStorage {
const values = new Map<string, string>();
return {
getItem(key) {
return values.has(key) ? values.get(key)! : null;
},
setItem(key, value) {
values.set(key, value);
},
removeItem(key) {
values.delete(key);
},
};
}
describe('gameSettingsStorage', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('falls back to defaults when nothing has been saved', () => {
vi.stubGlobal('window', {localStorage: createMemoryStorage()});
expect(readSavedSettings()).toEqual({
musicVolume: DEFAULT_MUSIC_VOLUME,
platformTheme: 'light',
});
});
it('reads legacy unversioned payloads and clamps the volume', () => {
const storage = createMemoryStorage();
storage.setItem('tavernrealms.settings.v1', JSON.stringify({musicVolume: 2}));
vi.stubGlobal('window', {localStorage: storage});
expect(readSavedSettings()).toEqual({
musicVolume: 1,
platformTheme: 'light',
});
});
it('reads stored platform theme when available', () => {
const storage = createMemoryStorage();
storage.setItem(
'tavernrealms.settings.v1',
JSON.stringify({musicVolume: 0.5, platformTheme: 'dark'}),
);
vi.stubGlobal('window', {localStorage: storage});
expect(readSavedSettings()).toEqual({
musicVolume: 0.5,
platformTheme: 'dark',
});
expect(normalizePlatformTheme('unknown')).toBe('light');
});
it('writes versioned settings payloads', () => {
const storage = createMemoryStorage();
vi.stubGlobal('window', {localStorage: storage});
writeSavedSettings({
musicVolume: clampVolume(0.6),
platformTheme: 'dark',
});
expect(JSON.parse(storage.getItem('tavernrealms.settings.v1') ?? '{}')).toEqual({
version: 1,
musicVolume: 0.6,
platformTheme: 'dark',
});
});
});

View File

@@ -0,0 +1,76 @@
import {
DEFAULT_MUSIC_VOLUME,
DEFAULT_PLATFORM_THEME,
type PlatformTheme,
type RuntimeSettings,
} from '../../packages/shared/src/contracts/runtime';
import {isRecord, readStoredJson, writeStoredJson} from './storage';
const SETTINGS_STORAGE_KEY = 'tavernrealms.settings.v1';
const SETTINGS_STORAGE_VERSION = 1;
export type SavedGameSettings = RuntimeSettings;
export { DEFAULT_MUSIC_VOLUME };
type StoredGameSettings = SavedGameSettings & {
version: number;
};
export function clampVolume(value: number) {
if (!Number.isFinite(value)) {
return DEFAULT_MUSIC_VOLUME;
}
return Math.max(0, Math.min(1, value));
}
export function normalizePlatformTheme(value: unknown): PlatformTheme {
return value === 'dark' ? 'dark' : DEFAULT_PLATFORM_THEME;
}
function parseSavedSettings(value: unknown): SavedGameSettings | null {
if (!isRecord(value)) {
return null;
}
if (value.version === SETTINGS_STORAGE_VERSION && typeof value.musicVolume === 'number') {
return {
musicVolume: clampVolume(value.musicVolume),
platformTheme: normalizePlatformTheme(value.platformTheme),
};
}
if (typeof value.musicVolume === 'number') {
return {
musicVolume: clampVolume(value.musicVolume),
platformTheme: normalizePlatformTheme(value.platformTheme),
};
}
return null;
}
export function readSavedSettings() {
return (
readStoredJson({
key: SETTINGS_STORAGE_KEY,
parse: parseSavedSettings,
}) ?? {
musicVolume: DEFAULT_MUSIC_VOLUME,
platformTheme: DEFAULT_PLATFORM_THEME,
}
);
}
export function writeSavedSettings(settings: SavedGameSettings) {
const payload: StoredGameSettings = {
version: SETTINGS_STORAGE_VERSION,
musicVolume: clampVolume(settings.musicVolume),
platformTheme: normalizePlatformTheme(settings.platformTheme),
};
return writeStoredJson({
key: SETTINGS_STORAGE_KEY,
value: payload,
});
}

View File

@@ -0,0 +1,255 @@
import { describe, expect, it } from 'vitest';
import type { GameState, StoryMoment } from '../types';
import { WorldType } from '../types';
import {
rehydrateSavedSnapshot,
resolveHydratedSnapshotState,
} from './runtimeSnapshot';
function createStory(text: string, streaming = false): StoryMoment {
return {
text,
options: [],
streaming,
};
}
function createHydratedBattleSnapshot(
gameStateOverrides: Partial<GameState> = {},
) {
return {
version: 3,
savedAt: '2026-04-14T00:00:00.000Z',
bottomTab: 'adventure' as const,
currentStory: createStory('战斗故事'),
gameState: {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: {
id: 'hero',
},
runtimeActionVersion: 3,
runtimeSessionId: 'runtime-main',
currentScene: 'Story',
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
storyHistory: [],
characterChats: {},
animationState: 'idle',
currentEncounter: {
kind: 'npc',
id: 'npc-fighter',
npcName: '断桥客',
npcDescription: '拦路的刀客',
context: '断桥对峙',
hostile: false,
},
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [
{
id: 'npc-fighter',
name: '断桥客',
hp: 18,
maxHp: 32,
description: '拦路的刀客',
levelProfile: {
level: 4,
referenceStrength: 202,
progressionRole: 'rival',
source: 'manual',
},
experienceReward: 20,
},
],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: true,
playerHp: 40,
playerMaxHp: 40,
playerMana: 18,
playerMaxMana: 18,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-fighter': {
affinity: -16,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: 'npc-fighter',
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...gameStateOverrides,
} as unknown as GameState,
};
}
describe('runtimeSnapshot', () => {
it('keeps server-hydrated snapshots unchanged', () => {
const snapshot = {
gameState: {
playerCharacter: {
id: 'sword-princess',
},
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
runtimeActionVersion: 3,
runtimeSessionId: 'runtime-main',
} as GameState,
currentStory: createStory('服务端恢复故事'),
bottomTab: 'inventory',
};
expect(resolveHydratedSnapshotState(snapshot)).toBe(snapshot);
});
it('only applies minimal local shape hydration for non-hydrated legacy snapshots', () => {
const snapshot = {
gameState: {
worldType: 'WUXIA',
customWorldProfile: null,
playerCharacter: {
id: 'sword-princess',
},
playerHp: 999,
playerMaxHp: 12,
playerMana: 999,
playerMaxMana: 12,
runtimeActionVersion: undefined,
runtimeSessionId: undefined,
playerEquipment: null,
} as unknown as GameState,
currentStory: createStory('旧快照故事', true),
bottomTab: 'unknown',
};
const hydrated = resolveHydratedSnapshotState(snapshot);
expect(hydrated.bottomTab).toBe('adventure');
expect(hydrated.currentStory?.streaming).toBe(false);
expect(hydrated.gameState.playerEquipment).toEqual({
weapon: null,
armor: null,
relic: null,
});
expect(hydrated.gameState.playerProgression).toEqual({
level: 1,
currentLevelXp: 0,
totalXp: 0,
xpToNextLevel: 60,
pendingLevelUps: 0,
lastGrantedSource: null,
});
expect(hydrated.gameState.playerMaxHp).toBe(12);
expect(hydrated.gameState.playerHp).toBe(12);
expect(hydrated.gameState.playerMaxMana).toBe(12);
expect(hydrated.gameState.playerMana).toBe(12);
});
it('rehydrates minimal npc battle snapshots into renderable combatants', () => {
const snapshot = createHydratedBattleSnapshot();
const hydrated = rehydrateSavedSnapshot(snapshot);
const hostileNpc = hydrated.gameState.sceneHostileNpcs[0];
expect(hydrated).not.toBe(snapshot);
expect(hostileNpc).toEqual(
expect.objectContaining({
id: 'npc-fighter',
name: '断桥客',
description: '拦路的刀客',
hp: 18,
maxHp: 32,
levelProfile: {
level: 4,
referenceStrength: 202,
progressionRole: 'rival',
source: 'manual',
},
experienceReward: 20,
attackRange: expect.any(Number),
speed: expect.any(Number),
animation: 'idle',
renderKind: 'npc',
encounter: expect.objectContaining({
kind: 'npc',
id: 'npc-fighter',
npcName: '断桥客',
xMeters: expect.any(Number),
}),
}),
);
});
it('does not rewrite already renderable npc battle snapshots', () => {
const snapshot = createHydratedBattleSnapshot({
sceneHostileNpcs: [
{
id: 'npc-fighter',
name: '断桥客',
action: '摆开架势,随时准备出手',
description: '拦路的刀客',
animation: 'idle',
xMeters: 3.2,
yOffset: 0,
facing: 'left',
attackRange: 1.8,
speed: 7,
hp: 18,
maxHp: 32,
levelProfile: {
level: 4,
referenceStrength: 202,
progressionRole: 'rival',
source: 'manual',
},
experienceReward: 20,
renderKind: 'npc',
encounter: {
kind: 'npc',
id: 'npc-fighter',
npcName: '断桥客',
npcDescription: '拦路的刀客',
npcAvatar: '',
context: '断桥对峙',
xMeters: 3.2,
},
},
],
});
expect(rehydrateSavedSnapshot(snapshot)).toBe(snapshot);
});
});

View File

@@ -0,0 +1,347 @@
import {
buildInitialNpcState,
createNpcBattleMonster,
} from '../data/npcInteractions';
import { normalizePlayerProgressionState } from '../data/playerProgression';
import type {
Encounter,
GameState,
NpcPersistentState,
SceneHostileNpc,
StoryMoment,
} from '../types';
import { WorldType } from '../types';
import type { BottomTab } from '../types/navigation';
import type {
HydratableGameState,
HydratedGameState,
HydratedSnapshotState,
SnapshotState,
} from './runtimeSnapshotTypes';
function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab {
return bottomTab === 'character' || bottomTab === 'inventory'
? bottomTab
: 'adventure';
}
function normalizeEquipmentLoadout(
playerEquipment: HydratableGameState['playerEquipment'],
) {
if (!playerEquipment || typeof playerEquipment !== 'object') {
return null;
}
return {
weapon: playerEquipment.weapon ?? null,
armor: playerEquipment.armor ?? null,
relic: playerEquipment.relic ?? null,
} satisfies GameState['playerEquipment'];
}
function createEmptyEquipmentLoadout() {
return {
weapon: null,
armor: null,
relic: null,
} satisfies GameState['playerEquipment'];
}
function resolveHydrationWorldType(worldType: GameState['worldType']) {
const normalizedWorldType =
typeof worldType === 'string' ? worldType.toUpperCase() : worldType;
if (normalizedWorldType === WorldType.WUXIA) {
return WorldType.WUXIA;
}
if (normalizedWorldType === WorldType.XIANXIA) {
return WorldType.XIANXIA;
}
if (normalizedWorldType === WorldType.CUSTOM) {
return WorldType.CUSTOM;
}
return null;
}
function hasRenderableRuntimeNpcBattleFields(hostileNpc: SceneHostileNpc) {
const candidate = hostileNpc as Partial<SceneHostileNpc>;
return Boolean(
candidate.encounter &&
typeof candidate.animation === 'string' &&
typeof candidate.xMeters === 'number' &&
typeof candidate.yOffset === 'number' &&
typeof candidate.facing === 'string' &&
typeof candidate.attackRange === 'number' &&
typeof candidate.speed === 'number',
);
}
function normalizeRuntimeBattleEncounter(
encounter: GameState['currentEncounter'],
): Encounter | null {
if (!encounter || encounter.kind !== 'npc') {
return null;
}
const npcName =
typeof encounter.npcName === 'string' ? encounter.npcName.trim() : '';
if (!npcName) {
return null;
}
return {
...encounter,
kind: 'npc',
npcName,
npcDescription:
typeof encounter.npcDescription === 'string'
? encounter.npcDescription
: '',
npcAvatar:
typeof encounter.npcAvatar === 'string' ? encounter.npcAvatar : '',
context: typeof encounter.context === 'string' ? encounter.context : '',
hostile: true,
levelProfile: encounter.levelProfile,
experienceReward: encounter.experienceReward,
} satisfies Encounter;
}
function resolveRuntimeNpcBattleState(
gameState: Pick<
GameState,
| 'currentBattleNpcId'
| 'currentEncounter'
| 'customWorldProfile'
| 'npcStates'
| 'sceneHostileNpcs'
| 'worldType'
>,
) {
const encounter = normalizeRuntimeBattleEncounter(gameState.currentEncounter);
if (!encounter || gameState.sceneHostileNpcs.length === 0) {
return null;
}
const npcStateKey =
gameState.currentBattleNpcId ?? encounter.id ?? encounter.npcName;
const npcState =
gameState.npcStates[npcStateKey] ??
buildInitialNpcState(
encounter,
resolveHydrationWorldType(gameState.worldType),
gameState as GameState,
);
return {
encounter,
npcState,
};
}
function hydrateRuntimeNpcBattleMonster(params: {
hostileNpc: SceneHostileNpc;
encounter: Encounter;
npcState: NpcPersistentState;
gameState: Pick<GameState, 'customWorldProfile' | 'worldType'>;
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
}) {
const template = createNpcBattleMonster(
params.encounter,
params.npcState,
params.battleMode,
{
worldType: resolveHydrationWorldType(params.gameState.worldType),
customWorldProfile: params.gameState.customWorldProfile,
},
);
const candidate = params.hostileNpc as Partial<SceneHostileNpc>;
const xMeters =
typeof candidate.xMeters === 'number'
? candidate.xMeters
: template.xMeters;
const yOffset =
typeof candidate.yOffset === 'number'
? candidate.yOffset
: template.yOffset;
return {
...template,
id:
typeof candidate.id === 'string' && candidate.id.trim()
? candidate.id
: template.id,
name:
typeof candidate.name === 'string' && candidate.name.trim()
? candidate.name
: template.name,
description:
typeof candidate.description === 'string'
? candidate.description
: template.description,
hp: typeof candidate.hp === 'number' ? candidate.hp : template.hp,
maxHp:
typeof candidate.maxHp === 'number' ? candidate.maxHp : template.maxHp,
animation:
typeof candidate.animation === 'string'
? candidate.animation
: template.animation,
xMeters,
yOffset,
facing:
candidate.facing === 'left' || candidate.facing === 'right'
? candidate.facing
: template.facing,
attackRange:
typeof candidate.attackRange === 'number'
? candidate.attackRange
: template.attackRange,
speed:
typeof candidate.speed === 'number' ? candidate.speed : template.speed,
levelProfile: candidate.levelProfile ?? template.levelProfile,
experienceReward:
typeof candidate.experienceReward === 'number'
? candidate.experienceReward
: template.experienceReward,
encounter: {
...template.encounter,
xMeters,
},
} satisfies SceneHostileNpc;
}
export function hydrateRuntimeNpcBattleGameState(
gameState: HydratedGameState,
): HydratedGameState {
const battleMode = gameState.currentNpcBattleMode;
if (
gameState.inBattle !== true ||
(battleMode !== 'fight' && battleMode !== 'spar') ||
gameState.currentEncounter?.kind !== 'npc' ||
gameState.sceneHostileNpcs.length === 0 ||
gameState.sceneHostileNpcs.every(hasRenderableRuntimeNpcBattleFields)
) {
return gameState;
}
const resolvedState = resolveRuntimeNpcBattleState(gameState);
if (!resolvedState) {
return gameState;
}
return {
...gameState,
sceneHostileNpcs: gameState.sceneHostileNpcs.map((hostileNpc) =>
hasRenderableRuntimeNpcBattleFields(hostileNpc)
? hostileNpc
: hydrateRuntimeNpcBattleMonster({
hostileNpc,
encounter: resolvedState.encounter,
npcState: resolvedState.npcState,
gameState,
battleMode,
}),
),
};
}
export function normalizeSavedStory(story: StoryMoment | null) {
if (!story) {
return null;
}
return {
...story,
streaming: false,
} satisfies StoryMoment;
}
export function normalizeSavedGameState(gameState: GameState) {
const hydratableState = gameState as HydratableGameState;
const resolvedEquipment = normalizeEquipmentLoadout(
hydratableState.playerEquipment,
);
const playerMaxHp = Math.max(1, hydratableState.playerMaxHp);
const playerMaxMana = Math.max(1, hydratableState.playerMaxMana);
return hydrateRuntimeNpcBattleGameState({
...hydratableState,
playerProgression: normalizePlayerProgressionState(
hydratableState.playerProgression ?? null,
),
playerMaxHp,
playerHp: Math.min(hydratableState.playerHp, playerMaxHp),
playerMaxMana,
playerMana: Math.min(hydratableState.playerMana, playerMaxMana),
playerEquipment: resolvedEquipment ?? createEmptyEquipmentLoadout(),
runtimeActionVersion:
typeof hydratableState.runtimeActionVersion === 'number'
? hydratableState.runtimeActionVersion
: 0,
runtimeSessionId:
typeof hydratableState.runtimeSessionId === 'string'
? hydratableState.runtimeSessionId
: null,
} satisfies HydratedGameState);
}
export function hydrateSnapshotState(snapshot: {
gameState: GameState;
currentStory: StoryMoment | null;
bottomTab: string;
}): HydratedSnapshotState {
return {
gameState: normalizeSavedGameState(snapshot.gameState),
currentStory: normalizeSavedStory(snapshot.currentStory),
bottomTab: normalizeBottomTab(snapshot.bottomTab),
};
}
export function isHydratedSnapshotState(
snapshot: SnapshotState,
): snapshot is HydratedSnapshotState {
const { gameState, currentStory, bottomTab } = snapshot;
return Boolean(
(bottomTab === 'adventure' ||
bottomTab === 'character' ||
bottomTab === 'inventory') &&
(!currentStory || currentStory.streaming !== true) &&
typeof gameState.runtimeActionVersion === 'number' &&
(gameState.runtimeSessionId === null ||
typeof gameState.runtimeSessionId === 'string') &&
(!gameState.playerCharacter ||
Boolean(
gameState.playerEquipment &&
typeof gameState.playerEquipment === 'object',
)),
);
}
export function rehydrateSavedSnapshot<T extends HydratedSnapshotState>(
snapshot: T,
): T {
const hydratedGameState = hydrateRuntimeNpcBattleGameState(
snapshot.gameState,
);
if (hydratedGameState === snapshot.gameState) {
return snapshot;
}
return {
...snapshot,
gameState: hydratedGameState,
};
}
export function resolveHydratedSnapshotState(snapshot: SnapshotState) {
return isHydratedSnapshotState(snapshot)
? rehydrateSavedSnapshot(snapshot)
: hydrateSnapshotState(snapshot);
}

View File

@@ -0,0 +1,31 @@
import type { GameState, StoryMoment } from '../types';
import type { BottomTab } from '../types/navigation';
import type { SavedGameSnapshot } from './gameSaveStorage';
export type HydratableGameState = GameState & {
playerEquipment?: GameState['playerEquipment'] | null;
};
export type HydratedGameState = GameState & {
playerEquipment: GameState['playerEquipment'];
runtimeActionVersion: number;
runtimeSessionId: string | null;
};
export type SnapshotState = {
gameState: GameState;
currentStory: StoryMoment | null;
bottomTab: string;
};
export type HydratedSnapshotState = {
gameState: HydratedGameState;
currentStory: StoryMoment | null;
bottomTab: BottomTab;
};
export type HydratedSavedGameSnapshot = Omit<
SavedGameSnapshot,
'gameState' | 'currentStory' | 'bottomTab'
> &
HydratedSnapshotState;

View File

@@ -0,0 +1,65 @@
import {describe, expect, it} from 'vitest';
import {isRecord, type JsonStorage,readStoredJson, removeStoredJson, writeStoredJson} from './storage';
function createMemoryStorage(): JsonStorage {
const values = new Map<string, string>();
return {
getItem(key) {
return values.has(key) ? values.get(key)! : null;
},
setItem(key, value) {
values.set(key, value);
},
removeItem(key) {
values.delete(key);
},
};
}
describe('storage helpers', () => {
it('reads parsed json values from storage', () => {
const storage = createMemoryStorage();
writeStoredJson({
key: 'example',
value: {value: 42},
storage,
});
const result = readStoredJson({
key: 'example',
storage,
parse: value => (isRecord(value) && typeof value.value === 'number' ? value.value : null),
});
expect(result).toBe(42);
});
it('returns null when persisted json cannot be parsed', () => {
const storage = createMemoryStorage();
storage.setItem('broken', '{not-valid-json');
const result = readStoredJson({
key: 'broken',
storage,
parse: () => 'unreachable',
});
expect(result).toBeNull();
});
it('removes persisted values', () => {
const storage = createMemoryStorage();
writeStoredJson({
key: 'example',
value: {value: 'keep'},
storage,
});
removeStoredJson('example', storage);
expect(storage.getItem('example')).toBeNull();
});
});

View File

@@ -0,0 +1,65 @@
export type JsonStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
export function getJsonStorage(storage?: JsonStorage | null) {
if (storage !== undefined) {
return storage;
}
if (typeof window === 'undefined') {
return null;
}
return window.localStorage;
}
export function readStoredJson<T>({
key,
parse,
storage,
}: {
key: string;
parse: (value: unknown) => T | null;
storage?: JsonStorage | null;
}) {
const resolvedStorage = getJsonStorage(storage);
if (!resolvedStorage) {
return null;
}
const raw = resolvedStorage.getItem(key);
if (!raw) {
return null;
}
try {
return parse(JSON.parse(raw));
} catch {
return null;
}
}
export function writeStoredJson({
key,
value,
storage,
}: {
key: string;
value: unknown;
storage?: JsonStorage | null;
}) {
const resolvedStorage = getJsonStorage(storage);
if (!resolvedStorage) {
return false;
}
resolvedStorage.setItem(key, JSON.stringify(value));
return true;
}
export function removeStoredJson(key: string, storage?: JsonStorage | null) {
getJsonStorage(storage)?.removeItem(key);
}