Exact source used by this build. SHA-256: 3d7617aebaef527458f93b53812026869110b2238f2cef4d5c864082492dcc29.

1 import { z } from "zod";
2 import type { Player, SimRules } from "../../types";
3 import { defineIntent, defineSystem } from "../registry";
4 
5 export const SKILL_IDS = ["farming", "mining", "foraging", "fishing", "combat"] as const;
6 export type SkillId = typeof SKILL_IDS[number];
7 export type StubProfessionHook = `${Exclude<SkillId, "farming">}_0${1 | 2 | 3 | 4 | 5 | 6}`;
8 export type ProfessionEffect =
9   | { kind: "sale"; category?: string; tag?: string; excludedTags?: string[]; multiplier: number }
10   | { kind: "growth"; reduction: number }
11   | { kind: "animal"; group: "coop" | "barn"; friendshipMultiplier: number; incubationMultiplier?: number; woolDaysReduction?: number; qualityBonus: number }
12   | { kind: "hook"; hook: StubProfessionHook };
13 export interface ProfessionDefinition { id: string; skill: SkillId; level: 5 | 10; parentId: string | null; effects: ProfessionEffect[]; name: string; description: string }
14 export interface SkillsContent { xpTable: number[]; skills: { id: SkillId; name: string; description: string }[]; professions: ProfessionDefinition[] }
15 export interface SkillProgress { xp: number; level: number }
16 export interface PendingLevel { skill: SkillId; level: number }
17 export interface SkillsState { skills: Record<SkillId, SkillProgress>; professions: string[]; pendingLevels: PendingLevel[] }
18 
19 function defaults(): SkillsState { return { skills: Object.fromEntries(SKILL_IDS.map((id) => [id, { xp: 0, level: 0 }])) as Record<SkillId, SkillProgress>, professions: [], pendingLevels: [] }; }
20 export function getSkills(player: Player): SkillsState {
21   const saved = player.systems.skills as Partial<SkillsState> | undefined;
22   return { ...defaults(), ...saved, skills: { ...defaults().skills, ...saved?.skills } };
23 }
24 export function getSkillsContent(rules: SimRules): SkillsContent | undefined { return rules.content?.skills as SkillsContent | undefined; }
25 export function skillLevel(player: Player, skill: SkillId): number { return getSkills(player).skills[skill].level; }
26 export function levelForXp(xp: number, table: readonly number[]): number { return table.reduce((level, threshold) => level + (xp >= threshold ? 1 : 0), 0); }
27 export function farmingHarvestXp(basePrice: number): number { return Math.round(16 * Math.log(0.018 * Math.max(0, basePrice) + 1)); }
28 export function grantXp(player: Player, skill: SkillId, amount: number, _rules: SimRules): Player {
29   if (!Number.isFinite(amount) || amount <= 0) return player;
30   const state = getSkills(player), progress = state.skills[skill];
31   return { ...player, systems: { ...player.systems, skills: { ...state, skills: { ...state.skills, [skill]: { ...progress, xp: progress.xp + Math.floor(amount) } } } } };
32 }
33 export function professionEffects(player: Player, rules: SimRules): ProfessionEffect[] {
34   const selected = new Set(getSkills(player).professions);
35   return getSkillsContent(rules)?.professions.filter((profession) => selected.has(profession.id)).flatMap((profession) => profession.effects) ?? [];
36 }
37 export function farmingGrowthMultiplier(player: Player, rules: SimRules): number {
38   return Math.max(0, 1 - professionEffects(player, rules).reduce((sum, effect) => sum + (effect.kind === "growth" ? effect.reduction : 0), 0));
39 }
40 export function professionSaleMultiplier(player: Player, item: { category: string; tags: readonly string[] }, rules: SimRules): number {
41   return professionEffects(player, rules).reduce((modifier, effect) => {
42     if (effect.kind !== "sale" || (effect.category && effect.category !== item.category) || (effect.tag && !item.tags.includes(effect.tag)) || effect.excludedTags?.some((tag) => item.tags.includes(tag))) return modifier;
43     return modifier * effect.multiplier;
44   }, 1);
45 }
46 /** Farming's animal branches are usable now by later animal producers without ID switches. */
47 export function farmingAnimalModifiers(player: Player, group: "coop" | "barn", rules: SimRules): { friendshipMultiplier: number; incubationMultiplier: number; woolDaysReduction: number; qualityBonus: number } {
48   return professionEffects(player, rules).reduce((result, effect) => effect.kind !== "animal" || effect.group !== group ? result : ({
49     friendshipMultiplier: result.friendshipMultiplier * effect.friendshipMultiplier,
50     incubationMultiplier: result.incubationMultiplier * (effect.incubationMultiplier ?? 1),
51     woolDaysReduction: result.woolDaysReduction + (effect.woolDaysReduction ?? 0), qualityBonus: result.qualityBonus + effect.qualityBonus,
52   }), { friendshipMultiplier: 1, incubationMultiplier: 1, woolDaysReduction: 0, qualityBonus: 0 });
53 }
54 /** Non-farming effects remain typed hooks for their future owning systems. */
55 export function hasProfessionHook(player: Player, hook: StubProfessionHook, rules: SimRules): boolean { return professionEffects(player, rules).some((effect) => effect.kind === "hook" && effect.hook === hook); }
56 
57 export const skillsSystem = defineSystem({
58   id: "skills", worldDefaults: () => ({}), playerDefaults: () => ({ ...defaults() }), events: ["skillLevelUp", "professionChosen", "levelAcknowledged"],
59   intents: [
60     defineIntent("chooseProfession", z.object({ type: z.literal("chooseProfession"), professionId: z.string().min(1) }), ({ state, player, playerId, rules }, intent) => {
61       const data = getSkillsContent(rules), profession = data?.professions.find((entry) => entry.id === intent.professionId), skills = getSkills(player);
62       if (!profession || skillLevel(player, profession.skill) < profession.level || (profession.parentId && !skills.professions.includes(profession.parentId))) return { state, events: [], error: "Profession is not available" };
63       if (data!.professions.some((entry) => entry.skill === profession.skill && entry.level === profession.level && skills.professions.includes(entry.id))) return { state, events: [], error: "A profession is already selected for this level" };
64       const next = { ...player, systems: { ...player.systems, skills: { ...skills, professions: [...skills.professions, profession.id] } } };
65       return { state: { ...state, revision: state.revision + 1, players: { ...state.players, [playerId]: next } }, events: [{ type: "professionChosen", playerId, data: { professionId: profession.id } }] };
66     }, { allowPaused: true }),
67     defineIntent("acknowledgeLevel", z.object({ type: z.literal("acknowledgeLevel"), skill: z.enum(SKILL_IDS), level: z.number().int().min(1).max(10) }), ({ state, player, playerId, rules }, intent) => {
68       const skills = getSkills(player);
69       if (!skills.pendingLevels.some((entry) => entry.skill === intent.skill && entry.level === intent.level)) return { state, events: [], error: "No pending level" };
70       if ([5, 10].includes(intent.level) && !getSkillsContent(rules)?.professions.some((entry) => entry.skill === intent.skill && entry.level === intent.level && skills.professions.includes(entry.id))) return { state, events: [], error: "Choose a profession first" };
71       const next = { ...player, systems: { ...player.systems, skills: { ...skills, pendingLevels: skills.pendingLevels.filter((entry) => entry.skill !== intent.skill || entry.level !== intent.level) } } };
72       return { state: { ...state, revision: state.revision + 1, players: { ...state.players, [playerId]: next } }, events: [{ type: "levelAcknowledged", playerId }] };
73     }, { allowPaused: true }),
74   ],
75   onOvernight: ({ state, rules }) => {
76     const table = getSkillsContent(rules)?.xpTable;
77     if (!table) return { state, events: [] };
78     const events: { type: string; playerId: string; data: { skill: SkillId; level: number } }[] = [];
79     const players = Object.fromEntries(Object.entries(state.players).map(([id, player]) => {
80       const saved = getSkills(player), pendingLevels = [...saved.pendingLevels], skills = { ...saved.skills };
81       let gained = false;
82       for (const skill of SKILL_IDS) {
83         const old = skills[skill], level = levelForXp(old.xp, table);
84         if (level <= old.level) continue;
85         gained = true; skills[skill] = { ...old, level };
86         for (let value = old.level + 1; value <= level; value++) { pendingLevels.push({ skill, level: value }); events.push({ type: "skillLevelUp", playerId: id, data: { skill, level: value } }); }
87       }
88       return [id, gained ? { ...player, energy: player.maxEnergy, systems: { ...player.systems, skills: { ...saved, skills, pendingLevels } } } : player];
89     }));
90     return events.length ? { state: { ...state, revision: state.revision + 1, players }, events } : { state, events: [] };
91   },
92 });
93